Skip to main content
Engineering7 min readMarch 3, 2026

Building a Blog With Nuxt Content: The Complete Guide

Everything you need to set up a production-ready blog using the Nuxt Content module — from MDX files to full-text search and RSS feeds.

James Ross Jr.

James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

I have built this exact thing more times than I can count: a content-driven site where the developer wants to write in Markdown, have it render beautifully, support full-text search, and not require a CMS subscription. Nuxt Content hits every one of those requirements, and the developer experience is genuinely pleasant once you understand how the pieces fit together.

This guide walks through building a production-ready blog with Nuxt Content from scratch. Not a toy example — the actual patterns I use on client sites.

Why Nuxt Content Over a Headless CMS

Before diving in, let me address the obvious question. When should you reach for Nuxt Content versus Contentful, Sanity, or another headless CMS?

Nuxt Content wins when the content authors are developers (or comfortable with Git), when you want zero runtime external dependencies, when content changes deploy with code, and when you need the flexibility to embed custom Vue components in your content. The files live in your repository, the build is self-contained, and there are no API rate limits or monthly subscription costs.

Headless CMS wins when non-technical editors need a visual interface, when content needs to be shared across multiple frontends, or when you need real-time preview workflows for a large editorial team.

For a developer portfolio, documentation site, or a small business blog where the developer manages content — Nuxt Content is the right call.

Initial Setup

Install the module and create your content directory:

npx nuxi module add content

Your nuxt.config.ts will get the module added automatically. The content directory at the root of your project is where all your Markdown files live.

content/
  blog/
    my-first-post.md
    building-with-nuxt.md
  pages/
    about.md

The directory structure becomes your URL structure by default, which is clean and predictable.

Frontmatter and Content Schema

Every blog post should have consistent frontmatter. I define this as a Zod schema and validate it at build time to catch missing fields before they reach production.

Here is the frontmatter structure I use:

---
title: "Your Post Title"
description: "150-160 character meta description for SEO"
date: 2026-03-03
category: Engineering
readTime: 7
tags:
  - Nuxt
  - Web Development
draft: false
---

In content.config.ts, you can define collections with typed schemas:

import { defineCollection, z } from '@nuxt/content'

export const collections = {
  blog: defineCollection({
    type: 'page',
    source: 'blog/**/*.md',
    schema: z.object({
      title: z.string(),
      description: z.string(),
      date: z.date(),
      category: z.string(),
      readTime: z.number(),
      tags: z.array(z.string()),
      draft: z.boolean().default(false),
    }),
  }),
}

This gives you full TypeScript inference when querying content. If a post is missing a required field, the build fails with a clear error message. That beats discovering a broken page in production.

Building the Blog List Page

The blog index page queries all posts, sorts them, and filters out drafts:

<script setup lang="ts">
const { data: posts } = await useAsyncData('blog-posts', () =>
  queryCollection('blog')
    .where('draft', '=', false)
    .order('date', 'DESC')
    .all()
)
</script>

<template>
  <div class="max-w-4xl mx-auto px-4 py-12">
    <h1 class="text-4xl font-bold mb-8">Writing</h1>
    <div class="space-y-8">
      <article v-for="post in posts" :key="post._path" class="border-b pb-8">
        <time class="text-sm text-gray-500">
          {{ new Date(post.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) }}
        </time>
        <h2 class="text-2xl font-semibold mt-2">
          <NuxtLink :to="post._path" class="hover:text-blue-600 transition-colors">
            {{ post.title }}
          </NuxtLink>
        </h2>
        <p class="text-gray-600 mt-2">{{ post.description }}</p>
        <div class="flex gap-2 mt-3">
          <span v-for="tag in post.tags" :key="tag"
            class="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded">
            {{ tag }}
          </span>
        </div>
      </article>
    </div>
  </div>
</template>

The Post Detail Page

Create pages/blog/[...slug].vue to handle individual post rendering:

<script setup lang="ts">
const route = useRoute()
const { data: post } = await useAsyncData(`post-${route.path}`, () =>
  queryCollection('blog').path(route.path).first()
)

if (!post.value) {
  throw createError({ statusCode: 404, statusMessage: 'Post not found' })
}

useSeoMeta({
  title: post.value.title,
  description: post.value.description,
  ogTitle: post.value.title,
  ogDescription: post.value.description,
})
</script>

<template>
  <article class="max-w-3xl mx-auto px-4 py-12" v-if="post">
    <header class="mb-8">
      <h1 class="text-4xl font-bold leading-tight">{{ post.title }}</h1>
      <div class="flex items-center gap-4 mt-4 text-sm text-gray-500">
        <time>{{ new Date(post.date).toLocaleDateString() }}</time>
        <span>{{ post.readTime }} min read</span>
      </div>
    </header>
    <ContentRenderer :value="post" class="prose prose-lg max-w-none" />
  </article>
</template>

The ContentRenderer component handles the heavy lifting — it renders your Markdown to HTML, processes MDC syntax, and applies any prose styles you have configured.

Custom Vue Components in Markdown

This is where Nuxt Content separates itself. You can use Vue components directly in your Markdown files using MDC (Markdown Components) syntax:

This is regular markdown text.

::alert{type="warning"}
This renders a custom Alert component with type="warning" prop.
::

Here is some inline text with a :badge[New Feature] component.

Create components/content/Alert.vue and it auto-imports into your content. This is powerful for documentation sites where you need callout boxes, code sandboxes, or interactive demos embedded in articles.

Nuxt Content includes a built-in search feature powered by a local index — no Algolia required for most use cases:

<script setup lang="ts">
const search = ref('')
const { data: results } = await useAsyncData(
  `search-${search.value}`,
  () => searchContent(search.value),
  { watch: [search] }
)
</script>

For larger sites, Nuxt Content integrates with Algolia DocSearch if you need more advanced ranking and filtering. But for a blog with under a few hundred posts, the built-in search is excellent and requires no external service.

RSS Feed

Every blog needs an RSS feed. Add a server route at server/routes/rss.xml.ts:

import { serverQueryContent } from '#content/server'
import RSS from 'rss'

export default defineEventHandler(async (event) => {
  const feed = new RSS({
    title: 'James Ross Jr. — Writing',
    site_url: 'https://jamesrossjr.com',
    feed_url: 'https://jamesrossjr.com/rss.xml',
  })

  const posts = await serverQueryContent(event, 'blog')
    .where({ draft: false })
    .sort({ date: -1 })
    .find()

  for (const post of posts) {
    feed.item({
      title: post.title,
      url: `https://jamesrossjr.com${post._path}`,
      description: post.description,
      date: post.date,
    })
  }

  setHeader(event, 'Content-Type', 'text/xml')
  return feed.xml()
})

Sitemap Integration

Install @nuxtjs/sitemap and it will automatically discover your content routes and include them in the generated sitemap. Add content-specific configuration if you want to control change frequency or priority:

// nuxt.config.ts
sitemap: {
  sources: ['/api/__sitemap__/urls'],
  defaults: {
    changefreq: 'weekly',
    priority: 0.8,
  },
}

Deployment and Static Generation

For a purely static blog, run nuxt generate. Nuxt Content works perfectly with static generation — all your Markdown gets processed at build time and you get fully static HTML files.

For sites that need server-side rendering (dynamic content, user-specific data), deploy to a Node.js host or use Cloudflare Pages with SSR enabled. Nuxt Content works in both modes without any configuration changes.

I recommend static generation for most blogs. The result is a fast, SEO-optimized site that can be hosted on Cloudflare Pages for free, with no server to maintain.

The Pattern I Follow on Every Content Site

Structure your content directory early and be consistent with your frontmatter schema. Add TypeScript validation to your content collection at the start of the project, not as an afterthought. Build the RSS feed on day one — it is a 30-minute task that pays dividends in reach. Use prose Tailwind plugin for article typography. Keep components in components/content/ so they auto-import into MDC.

Nuxt Content is a mature, well-designed tool. Once you understand the content collection API and how MDC works, you can build sophisticated content sites quickly. The combination of Git-based content, Vue components in Markdown, and static generation is genuinely powerful.


Building a content site with Nuxt and want help with architecture, SEO configuration, or deployment? Book a call and we can work through the specifics together: calendly.com/jamesrossjr.


Keep Reading