
I've been a big fan of Nuxt2 and Nuxt3 is definitely a learning curve and I have to admit the documentation is a bit lacking—lots of small fragments and many different ways to do things.
Updated October 2026: Rewritten for Nuxt Content v3.
queryContentand<ContentRendererMarkdown>are gone; v3 uses collections defined incontent.config.ts,queryCollection(...)and a single<ContentRenderer>. Typed routes are now built into Nuxt so nuxt-typed-router is no longer needed. The original v2 sample is kept at the end.
What I wanted
I basically wanted a simple example or page that looks at the slug, finds the markdown content for it and renders it unless it's missing in which case returns a 404. Oh, and I want to use Typescript.
Simple right? Yeah, not so much if you want to use...
The Composition API
The composition API (that's the <script setup> bit) is the new hotness but means you have to use different functions and return different structures from Nuxt3. The other API is still around, which means a lot of the samples you find online aren't going to work. Things like useAsyncData instead of the old asyncData etc. It can be quite overwhelming when all the snippets are using a different mechanism that doesn't easily port.
Grabbing the slug
You now need to useRoute to get the current route and then you can grab parameters off it. If you want to use Typescript, turn on experimental: { typedPages: true } in your nuxt.config.ts and you can pass the route name into useRoute which will strongly-type the parameters so route.params.slug autocompletes and compiles without warnings. (Back in 2023 this needed nuxt-typed-router.)
Nice.
Defining a collection
Content v3 wants to know what your content looks like up-front. You define one or more collections in content.config.ts with a source glob and a Zod schema for the front matter. A page collection gets a path worked out from the file location so content/blog/hello.md becomes /blog/hello.
import { defineCollection, defineContentConfig, z } from "@nuxt/content"
export default defineContentConfig({
collections: {
blog: defineCollection({
type: "page",
source: "blog/**/*.md",
schema: z.object({
date: z.string().optional(),
tags: z.array(z.string()).default([]),
}),
}),
},
})
title and description come for free with page collections. Anything else you want back from a query needs to be in the schema.
Querying content
The content system has changed quite a lot again. v3 stores everything in SQLite and you query it with queryCollection inside of useAsyncData. To grab a single page you use .path(...) and .first(), for lists you use .where(...), .order(...) and .all(). Because it's tied to your collection's schema the results are strongly typed too.
Note that the first parameter into useAsyncData is effectively a cache key for rendering the page so don't let it collide with a component that's also rendering on that page.
404 when not found
None of the snippets I could find showed how to return a 404 with the composition useAsyncData pattern (the non-composition one returns an error object which makes life simpler).
createError is your friend and just throw what it creates with the right statusCode should the content result be missing.
Output Front Matter elements
This turned out to be an easy part—binding is pretty much the same. {{ something }} for inner-Text and v-bind:attribute for attributes. The only oddity here was having to ?. the properties because Typescript believes they can be null.
Render the Markdown
This is simpler than it used to be. In v3 there's a single <ContentRenderer> component that takes the whole document via :value and renders its body. You can put a class on it to style the output (this site hangs its Tailwind prose classes off it).
Set the page title etc.
Finally we need to set the page title and description. useSeoMeta is the tidiest way to do that now and we can feed it straight from the post's Front Matter.
Show me the code
Okay, here's the sample for displaying a single blog post using the composition API and all we talked about.
<template>
<article v-if="post">
<h1>{{ post.title }}</h1>
<ContentRenderer :value="post" />
</article>
</template>
<script setup lang="ts">
const route = useRoute("blog-slug")
const slug = route.params.slug
const { data: post } = await useAsyncData("blog-" + slug, () =>
queryCollection("blog").path(`/blog/${slug}`).first()
)
if (!post.value) throw createError({ statusCode: 404, message: "Post not found" })
useSeoMeta({
title: post.value.title,
description: post.value.description,
})
</script>
You would name this file /app/pages/blog/[slug].vue in Nuxt 4 (or /pages/blog/[slug].vue on a Nuxt 3 layout).
If you're using JavaScript...
If you don't want to use TypeScript remove lang="ts" as well as the blog-slug from useRoute and all should be fine.
Have fun!
Original (Content v2)
For reference, this is what the page looked like with Content v2 using queryContent and the two-component renderer. It won't work on v3.
<template>
<article>
<h1>{{ post?.title }}</h1>
<ContentRenderer :value="post">
<ContentRendererMarkdown :value="post" />
</ContentRenderer>
</article>
</template>
<script setup lang="ts">
const route = useRoute('blog-slug')
const { data: post } = await useAsyncData('post/' + route.params.slug, () => queryContent('blog', route.params.slug).findOne())
if (post.value == null) throw createError({ statusCode: 404, message: 'Post not found' })
useHead({
title: post.value.title,
meta: [
{
hid: 'description',
name: 'description',
content: post.value.description,
},
})
</script>
0 responses