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 that enables the creation of various types of websites, including static ones. Empowered with abstractions like routing, server-side rendering (SSR), and 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 setup, simply utilize the [...slug].vue as per the standard configuration.

pages/[...slug
<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
<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>

Whenever you navigate to domain.com/blog/first-article, the [...slug].vue file will correctly retrieve the associated content. Additionally, this setup preserves the functionality of existing routes and pages managed through Nuxt's file routing system.

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>

Final Thoughts

This setup allows to manage and display blog posts at a custom URL path enabling the use of markdown files to write blog posts, in a simple Nuxt.js application, leveraging the @nuxt/content module for content management and still keeping the Nuxt's file routing system.


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/']
    }
  },