---
title: "Nuxt Content v3: a Markdown blog in an afternoon · vuejs.school"
description: "Nuxt Content 3 turns a folder of Markdown files into a typed, queryable collection. Build a blog with a list page and a post page in a Nuxt 3 app."
canonical_url: "https://vuejs.school/community/blog/nuxt-content-3-a-markdown-blog-in-an-afternoon"
meta:
  "og:description": "Nuxt Content 3 turns a folder of Markdown files into a typed, queryable collection. Build a blog with a list page and a post page in a Nuxt 3 app."
  "og:image": "https://vuejs.school/_og/d/a_~TnV4dCBDb250ZW50IHYzOiBhIE1hcmtkb3duIGJsb2cgaW4gYW4gYWZ0ZXJub29uIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~TnV4dCBDb250ZW50IHYzOiBhIE1hcmtkb3duIGJsb2cgaW4gYW4gYWZ0ZXJub29u%2Cdescription_~TnV4dCBDb250ZW50IDMgdHVybnMgYSBmb2xkZXIgb2YgTWFya2Rvd24gZmlsZXMgaW50byBhIHR5cGVkLCBxdWVyeWFibGUgY29sbGVjdGlvbi4gQnVpbGQgYSBibG9nIHdpdGggYSBsaXN0IHBhZ2UgYW5kIGEgcG9zdCBwYWdlIGluIGEgTnV4dCAzIGFwcC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9udXh0LWNvbnRlbnQtMy1hLW1hcmtkb3duLWJsb2ctaW4tYW4tYWZ0ZXJub29uIg%2Cs_DEkyQCHC_CV1u9N7.png"
  "og:image:alt": "Nuxt Content v3: a Markdown blog in an afternoon · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Nuxt Content v3: a Markdown blog in an afternoon · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~TnV4dCBDb250ZW50IHYzOiBhIE1hcmtkb3duIGJsb2cgaW4gYW4gYWZ0ZXJub29uIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~TnV4dCBDb250ZW50IHYzOiBhIE1hcmtkb3duIGJsb2cgaW4gYW4gYWZ0ZXJub29u%2Cdescription_~TnV4dCBDb250ZW50IDMgdHVybnMgYSBmb2xkZXIgb2YgTWFya2Rvd24gZmlsZXMgaW50byBhIHR5cGVkLCBxdWVyeWFibGUgY29sbGVjdGlvbi4gQnVpbGQgYSBibG9nIHdpdGggYSBsaXN0IHBhZ2UgYW5kIGEgcG9zdCBwYWdlIGluIGEgTnV4dCAzIGFwcC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9udXh0LWNvbnRlbnQtMy1hLW1hcmtkb3duLWJsb2ctaW4tYW4tYWZ0ZXJub29uIg%2Cs_DEkyQCHC_CV1u9N7.png"
  "twitter:image:alt": "Nuxt Content v3: a Markdown blog in an afternoon · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## Collections, SQL storage and a query builder

Four changes in the [announcement](https://content.nuxt.com/blog/v3) 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 stuck**3 questions from the Modules, plugins and layers quiz.

[**Practice now**](https://vuejs.school/learn/nuxt/modules-and-the-nuxt-ecosystem?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Release notes · 7 min read**What's new in Nuxt 4.6: nuxt/server, typed $fetch, cookie sessions and Vapor**](https://vuejs.school/community/blog/whats-new-in-nuxt-4-6) [Tutorial · 6 min read**useFetch or $fetch? Picking the right one in Nuxt**](https://vuejs.school/community/blog/usefetch-or-fetch-picking-the-right-one-in-nuxt)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/nuxt-content-3-a-markdown-blog-in-an-afternoon#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-01-30","description":"Nuxt Content 3 turns a folder of Markdown files into a typed, queryable collection. Build a blog with a list page and a post page in a Nuxt 3 app.","name":"Nuxt Content v3: a Markdown blog in an afternoon","url":"https://vuejs.school/community/blog/nuxt-content-3-a-markdown-blog-in-an-afternoon","breadcrumb":{"@id":"https://vuejs.school/community/blog/nuxt-content-3-a-markdown-blog-in-an-afternoon#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/nuxt-content-3-a-markdown-blog-in-an-afternoon"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/nuxt-content-3-a-markdown-blog-in-an-afternoon#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-01-30","description":"Nuxt Content 3 turns a folder of Markdown files into a typed, queryable collection. Build a blog with a list page and a post page in a Nuxt 3 app.","headline":"Nuxt Content v3: a Markdown blog in an afternoon","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~TnV4dCBDb250ZW50IHYzOiBhIE1hcmtkb3duIGJsb2cgaW4gYW4gYWZ0ZXJub29uIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~TnV4dCBDb250ZW50IHYzOiBhIE1hcmtkb3duIGJsb2cgaW4gYW4gYWZ0ZXJub29u%2Cdescription_~TnV4dCBDb250ZW50IDMgdHVybnMgYSBmb2xkZXIgb2YgTWFya2Rvd24gZmlsZXMgaW50byBhIHR5cGVkLCBxdWVyeWFibGUgY29sbGVjdGlvbi4gQnVpbGQgYSBibG9nIHdpdGggYSBsaXN0IHBhZ2UgYW5kIGEgcG9zdCBwYWdlIGluIGEgTnV4dCAzIGFwcC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9udXh0LWNvbnRlbnQtMy1hLW1hcmtkb3duLWJsb2ctaW4tYW4tYWZ0ZXJub29uIg%2Cs_DEkyQCHC_CV1u9N7.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/nuxt-content-3-a-markdown-blog-in-an-afternoon#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/nuxt-content-3-a-markdown-blog-in-an-afternoon#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/nuxt-content-3-a-markdown-blog-in-an-afternoon#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Nuxt Content v3: a Markdown blog in an afternoon","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~TnV4dCBDb250ZW50IHYzOiBhIE1hcmtkb3duIGJsb2cgaW4gYW4gYWZ0ZXJub29uIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~TnV4dCBDb250ZW50IHYzOiBhIE1hcmtkb3duIGJsb2cgaW4gYW4gYWZ0ZXJub29u%2Cdescription_~TnV4dCBDb250ZW50IDMgdHVybnMgYSBmb2xkZXIgb2YgTWFya2Rvd24gZmlsZXMgaW50byBhIHR5cGVkLCBxdWVyeWFibGUgY29sbGVjdGlvbi4gQnVpbGQgYSBibG9nIHdpdGggYSBsaXN0IHBhZ2UgYW5kIGEgcG9zdCBwYWdlIGluIGEgTnV4dCAzIGFwcC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9udXh0LWNvbnRlbnQtMy1hLW1hcmtkb3duLWJsb2ctaW4tYW4tYWZ0ZXJub29uIg%2Cs_DEkyQCHC_CV1u9N7.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~TnV4dCBDb250ZW50IHYzOiBhIE1hcmtkb3duIGJsb2cgaW4gYW4gYWZ0ZXJub29uIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_~TnV4dCBDb250ZW50IHYzOiBhIE1hcmtkb3duIGJsb2cgaW4gYW4gYWZ0ZXJub29u%2Cdescription_~TnV4dCBDb250ZW50IDMgdHVybnMgYSBmb2xkZXIgb2YgTWFya2Rvd24gZmlsZXMgaW50byBhIHR5cGVkLCBxdWVyeWFibGUgY29sbGVjdGlvbi4gQnVpbGQgYSBibG9nIHdpdGggYSBsaXN0IHBhZ2UgYW5kIGEgcG9zdCBwYWdlIGluIGEgTnV4dCAzIGFwcC4%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9udXh0LWNvbnRlbnQtMy1hLW1hcmtkb3duLWJsb2ctaW4tYW4tYWZ0ZXJub29uIg%2Cs_DEkyQCHC_CV1u9N7.png"}]}
```