How to setup a simple blog with Nuxt.js

I was looking for a simple Blog with file-based markdown or similar, to avoid databases or APIs layer and to reduce the load on the server and be able to just serve static files.

I know there are multiple solutions out there, but my focus was to keep it fast and simple.

I've been using platforms like WordPress and Jekyll while exploring JAMStack options too, but my preference for Vue.js led me to wonder if Nuxt might just be the thing since it has a module for that

Nuxt is a Vue.js Framework for building websites, including static ones. It comes with routing, server-side rendering (SSR), static site generation (SSG) and more.

1. Set up Nuxt and dependencies

The easy way to setup

npx nuxi@latest init content-app -t content

This installs nuxt and content module

2. The content directory

Content Module creates a content directory for us. Where we'll put our Blog posts, as markdown files, easy as that:

/content
  ├ first-article.md
  ├ second-article.md
  ├ ....

3. Decide the structure of pages

I chose to use a multi-page blog setup, with index.vue as the landing/main page and a separate blog folder for the posts, to enable a different route like domain.com/blog.

/pages
├─ index.vue
├─ blog
│  ├─ [...slug].vue

Or, you can just put the [...slug].vue under pages to have the default single page pattern.

4. Setup the blog to fetch the right content

For a single-page blog, just use [...slug].vue with the default configuration.

pages/[...slug].vue
<template>
  <main>
    <ContentDoc />
  </main>
</template>

In the case of a multi-page blog setup, you should tweak the [...slug].vue file to fetch the right content.

pages/blog/[...slug].vue
<template>
  <main class="container">
    <ContentQuery :where="where" find="one">
      <template #default="{ data }">
        <ContentRenderer :value="data" />
      </template>
      <template #not-found>
        <p>No article found.</p>
      </template>
    </ContentQuery>
  </main>
</template>

<script setup>
const route = useRoute();
const slug = route.params.slug || [];
const where = { _path: "/" + slug.join("/") };
</script>

When you navigate to domain.com/blog/first-article, the [...slug].vue file fetches the matching content. Your existing routes and pages from Nuxt's file routing still work normally.

5. What happens when visiting domain.com/blog/ ?

We setup a content/index.md to serve as the main page for the blog. I've setup like this:

---
navigation: false
---

Blog

:the-index

The :the-index is a component at components/content/TheIndex.vue that renders a custom listing of posts sorted by date (date is a custom property in the markdown header file). Something like this:

components/content/TheIndex.vue
<script>
const queryBuilder = queryContent()
  .where({
    navigation: { $not: false },
  })
  .sort({ date: -1 })
  .find();
const { data: navigation } = await useAsyncData(
  "navigation",
  () => queryBuilder
);
// ... logic to use navigation and render listing (for-loop navigation)
</script>

Wrapping up

That's it. Blog posts as markdown files, served at a custom URL path with @nuxt/content, without breaking Nuxt's file routing.


ALERT There are known issues (more like challenges) when using SSG or pre-render and using dynamic routes or catch-all routes (as queryCount() and [...slug]).

To address this, there are many workarounds. My approach was modifying nitro config. To explicitly filter out pages that should be pre-rendered.

nuxt.config.ts
  nitro: {
    prerender: {
      ignore: ['/blog/Nuxt'],
      routes: ['/blog/']
    }
  },