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.tsfile withdefineCollection(). A collection has a type, a source pattern and an optional schema written with Zod, the validation library whosezobject 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 bywhere,order,limit,select,pathand thenall()orfirst().ContentRendererturns 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:
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:
<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:
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 apathworked out from its location:content/blog/hello-world.mdbecomes/blog/hello-world.source: 'blog/*.md'says which files belong to this collection, relative tocontent/. Another kind of content gets a collection of its own, with its own source and schema.schemaadds 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.
---
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.
<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.
<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 awhereon thepathcolumn, andfirst()returns one entry ornull.- The
useAsyncDatakey isroute.path, so two posts never share a cached result. - When nothing matches,
createErrorwith a 404 status gives the visitor a proper not-found page instead of an empty article.fatal: trueshows it after a client-side navigation too, not only when the server renders the page. ContentRenderertakes the whole entry throughvalueand renders itsbody, including the## Why Markdownheading 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.