Skip to content
TutorialNuxt

Nuxt Content v3: a Markdown blog in an afternoon

Nuxt Content 3.0.0 was published on 16 January. Collections, a schema and a SQL-backed query builder replace the old document-driven setup. Here is a complete blog on it.

vuejs.school team30 Jan 20256 min readNuxt 3.15 · Nuxt Content 3.0

Nuxt Content is the module that lets a Nuxt site read Markdown, YAML and JSON files from a content/ folder as if they were data. Version 3 rewrote how that works. By the end of this post you will have a Nuxt 3 app with a blog: Markdown posts in a folder, a typed collection with a schema, a page that lists the posts newest first, and a page that renders one post.

Collections, SQL storage and a query builder

Four changes in the announcement matter for this blog:

  • Collections. Content is organised into collections, declared in a content.config.ts file with defineCollection(). A collection has a type, a source pattern and an optional schema written with Zod, the validation library whose z object Nuxt Content re-exports.
  • SQL storage. Content is stored in a SQL database instead of files. In development and on a normal server that is SQLite, and the 3.0 package brings its own driver, so there is nothing to install. For serverless hosts the module ships adaptors that keep the data outside the instance; the announcement names Vercel with Postgres and NuxtHub with D1.
  • A database in the browser. The first query in the browser downloads a dump of the database and loads it into a WASM build of SQLite. After that, navigation between posts runs its queries locally without calling the server.
  • A query builder and a renderer. You query with queryCollection('blog') followed by where, order, limit, select, path and then all() or first(). ContentRenderer turns the parsed Markdown into HTML and Vue components.

The old document-driven mode, where every Markdown file became a page automatically, was removed. You write the list and post pages yourself.

Three more helpers sit next to queryCollection: queryCollectionNavigation builds a navigation tree for a collection, queryCollectionItemSurroundings fetches the previous and next entries around a path, and queryCollectionSearchSections returns searchable sections. The Studio editing platform's preview API is now built into the module, so a site that wants in-browser editing no longer installs a separate Studio module.

Create a Nuxt 3 project with the module

Scaffold a Nuxt 3 app, then add the module:

npx nuxi@latest init my-blog
cd my-blog
npm install @nuxt/content@3

Register it in the config:

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/content'],
})

A fresh Nuxt app has no pages/ folder, and app.vue shows a welcome component. Replace app.vue so that the router renders pages:

app.vue
<template>
  <NuxtPage />
</template>

Nuxt creates the pages/ routes automatically once the folder exists. You will create it in a moment.

Define a blog collection with a schema

Create content.config.ts at the project root. This is the one new file v3 asks for:

content.config.ts
import { defineCollection, defineContentConfig, z } from '@nuxt/content'

export default defineContentConfig({
  collections: {
    blog: defineCollection({
      type: 'page',
      source: 'blog/*.md',
      schema: z.object({
        date: z.date(),
        tags: z.array(z.string()).optional(),
      }),
    }),
  },
})

The file makes three decisions:

  • type: 'page' means each file maps to one page on the site, so the module gives every entry a path worked out from its location: content/blog/hello-world.md becomes /blog/hello-world.
  • source: 'blog/*.md' says which files belong to this collection, relative to content/. Another kind of content gets a collection of its own, with its own source and schema.
  • schema adds the fields you care about on top of the ones every page collection already has. It decides the columns of the collection's table and gives you TypeScript types on every query.

Nuxt Content 3.0 does not check your files against the schema: a post without a date is still stored, with date empty, and the build does not fail.

You do not declare the built-in fields; the module fills them in. Every collection has id, stem, extension and meta. A page collection adds path, title, description, seo, body and navigation. You may redefine a page field in your schema to change its type, but id is reserved: it is the entry's unique identifier, built from the collection name and the file's path, and it replaces any id a post sets in its frontmatter. If you need a human-friendly key, call it slug or key.

Write two posts with frontmatter

Frontmatter is the block of key-value pairs between --- lines at the top of a Markdown file. title and description fill the built-in fields; date and tags match the schema.

content/blog/hello-world.md
---
title: Hello, world
description: The first post on this blog, and why it exists.
date: 2025-01-29
tags: [meta]
---

Welcome. This blog is written in Markdown and rendered by Nuxt Content.

## Why Markdown

Because a post should be a text file you can edit anywhere.

Make a second file, content/blog/second-post.md, with a later date and different text, so the list has something to sort.

List the posts, newest first

The list page lives at pages/blog/index.vue. It queries the collection inside useAsyncData, which runs the query on the server during rendering and hands the result to the browser in the page payload, so the client does not query again on load.

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

<template>
  <main>
    <h1>Blog</h1>
    <ul>
      <li v-for="post in posts" :key="post.id">
        <NuxtLink :to="post.path">{{ post.title }}</NuxtLink>
        <p>{{ post.description }}</p>
      </li>
    </ul>
  </main>
</template>

order('date', 'DESC') sorts newest first in the database, not in JavaScript. all() returns an array, typed from your schema, so post.tags is string[] | undefined and post.path is a string. The built-in id is the right choice for :key.

The builder has the other clauses you would expect from SQL. .where('date', '>', '2025-01-01') keeps only newer posts, and .limit(5) is enough for a homepage teaser. .select('id', 'path', 'title', 'description') trims the payload when the list does not need the body; keep id in it, because the template uses it as the key. Chain them before all() in any order.

Render one post with ContentRenderer

The post page is pages/blog/[slug].vue. Square brackets make the file name a route parameter, so /blog/hello-world renders this page. You do not need the parameter itself: the current route.path is exactly the path the collection stored.

pages/blog/[slug].vue
<script setup lang="ts">
const route = useRoute()

const { data: post } = await useAsyncData(route.path, () =>
  queryCollection('blog').path(route.path).first(),
)

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

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

<template>
  <article v-if="post">
    <h1>{{ post.title }}</h1>
    <p>{{ new Date(post.date).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }) }}</p>
    <ContentRenderer :value="post" />
  </article>
</template>

What the lines do:

  • path(route.path) is a where on the path column, and first() returns one entry or null.
  • The useAsyncData key is route.path, so two posts never share a cached result.
  • When nothing matches, createError with a 404 status gives the visitor a proper not-found page instead of an empty article. fatal: true shows it after a client-side navigation too, not only when the server renders the page.
  • ContentRenderer takes the whole entry through value and renders its body, including the ## Why Markdown heading from the file.

For previous and next links under the article, queryCollectionItemSurroundings('blog', route.path) returns the two neighbours in one call, as [previous, next]. By default the neighbours follow file names; chain .order('date', 'DESC') to match the list page. Call it inside useAsyncData, like the other queries.

Run it and generate a static site

Run npm run dev and open /blog. The two posts are listed newest first, and each link opens its page. For a static site, run:

npm run generate

The announcement lists static generation as a zero-config target. Nuxt finds the post pages by following the links on /blog and pre-renders each one to HTML in .output/public. The database dump that the browser downloads is written there too, so client-side queries still work after you deploy the folder to any static host. To check it before deploying, serve the folder with npx serve .output/public and open /blog.

Check it stuck3 questions from the Modules, plugins and layers quiz.
Practice now
Was this helpful?

Keep reading