
A practical walkthrough of @nuxt/content for querying, rendering, and navigating markdown-based content in Nuxt.
Reza Baar
April 8, 2026
If you're building a blog, documentation site, or any content-heavy app with Nuxt, you'll want to look at @nuxt/content. It turns your markdown files into a queryable data layer with zero database setup. In this post (about 10 mins), we'll go from a basic markdown page to a full content list with filtering and navigation.
First, let's add the module to an existing Nuxt project:
npx nuxi module add content
This registers @nuxt/content in your nuxt.config.ts automatically:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/content'],
});
Now create a content/ directory at the root of your project (so outside app folder if you’re using Nuxt v4). Any .md file you place in here becomes queryable content.
Let's start simple. Create a markdown file:
<!-- content/hello.md -->
---
title: Hello World
description: My first content page
date: 2026-03-20
---
# Hello World
This is a content page rendered by `@nuxt/content`.
To render it, we need a catch-all route. Create a page with the <ContentDoc> component:
<!-- pages/[...slug].vue -->
<template>
<main>
<ContentDoc />
</main>
</template>
That's it. Navigate to /hello and you'll see your markdown rendered as HTML. The <ContentDoc> component handles fetching and rendering based on the current route path.
The default rendering works, but you probably want control over the layout. We can use the v-slot to access the document data:
<!-- pages/[...slug].vue -->
<template>
<main>
<ContentDoc v-slot="{ doc }">
<article>
<h1>{{ doc.title }}</h1>
<p class="text-gray-500">{{ doc.description }}</p>
<time>{{ new Date(doc.date).toLocaleDateString() }}</time>
<ContentRenderer :value="doc" />
</article>
</ContentDoc>
</main>
</template>
Here we pull the frontmatter fields (title, description, date) out of the document and render them ourselves. The <ContentRenderer> component takes care of the actual markdown body.
Rendering a single page is fine, but the real power comes from querying. Let's build a blog index that lists all posts.
Create a few more markdown files in content/blog/:
<!-- content/blog/first-post.md -->
---
title: First Post
description: Getting started with Nuxt Content
date: 2026-03-15
tags: ["nuxt", "getting-started"]
---
The body of the first post.
<!-- content/blog/second-post.md -->
---
title: Second Post
description: Going deeper with queries
date: 2026-03-20
tags: ["nuxt", "advanced"]
---
The body of the second post.
Now let's query them:
<!-- pages/blog/index.vue -->
<script setup lang="ts">
const { data: posts } = await useAsyncData('blog-posts', () =>
queryContent('blog')
.sort({ date: -1 })
.find()
);
</script>
<template>
<main>
<h1>Blog</h1>
<ul>
<li v-for="post in posts" :key="post._path">
<NuxtLink :to="post._path">
<h2>{{ post.title }}</h2>
<p>{{ post.description }}</p>
</NuxtLink>
</li>
</ul>
</main>
</template>
queryContent('blog') scopes the query to the content/blog/ directory. We sort by date descending and call .find() to get all matching documents. Each document comes with _path which maps directly to the route.
Wrapping the query in useAsyncData is important. It makes sure the data is fetched on the server during SSR and properly hydrated on the client.
Let's say we want to filter posts by tag. We can chain .where() onto the query:
<!-- pages/blog/index.vue -->
<script setup lang="ts">
const route = useRoute();
const activeTag = computed(() => route.query.tag as string | undefined);
const { data: posts } = await useAsyncData(
`blog-posts-${activeTag.value || 'all'}`,
() => {
const query = queryContent('blog').sort({ date: -1 });
if (activeTag.value) {
query.where({ tags: { $contains: activeTag.value } });
}
return query.find();
},
{ watch: [activeTag] }
);
</script>
<template>
<main>
<h1>Blog</h1>
<nav>
<NuxtLink to="/blog">All</NuxtLink>
<NuxtLink to="/blog?tag=nuxt">Nuxt</NuxtLink>
<NuxtLink to="/blog?tag=advanced">Advanced</NuxtLink>
</nav>
<ul>
<li v-for="post in posts" :key="post._path">
<NuxtLink :to="post._path">
<h2>{{ post.title }}</h2>
<p>{{ post.description }}</p>
</NuxtLink>
</li>
</ul>
</main>
</template>
A few things to note here. The $contains operator checks if the tags array includes the given value. We pass { watch: [activeTag] } to useAsyncData so the query re-runs when the tag filter changes. The cache key includes the active tag to avoid stale data across filters.
When listing posts, you don't need the full markdown body. Use .only() to select specific fields:
const { data: posts } = await useAsyncData('blog-list', () =>
queryContent('blog')
.only(['title', 'description', 'date', '_path', 'tags'])
.sort({ date: -1 })
.find()
);
This keeps the payload small, especially when you have a lot of content.
For documentation sites, you often want a sidebar that reflects the content structure. Nuxt Content provides fetchContentNavigation() for this:
<!-- components/ContentSidebar.vue -->
<script setup lang="ts">
const { data: navigation } = await useAsyncData('content-nav', () =>
fetchContentNavigation()
);
</script>
<template>
<aside>
<ul v-if="navigation">
<li v-for="item in navigation" :key="item._path">
<NuxtLink :to="item._path">{{ item.title }}</NuxtLink>
<ul v-if="item.children?.length">
<li v-for="child in item.children" :key="child._path">
<NuxtLink :to="child._path">{{ child.title }}</NuxtLink>
</li>
</ul>
</li>
</ul>
</aside>
</template>
The navigation tree is auto-generated from your content/ directory structure. Folders become parent items and markdown files become children. The title field comes from each file's frontmatter.
One of the best features of @nuxt/content is that you can use Vue components directly in your markdown files. Create a component in components/content/:
<!-- components/content/Callout.vue -->
<script setup lang="ts">
defineProps<{
type: 'info' | 'warning' | 'tip';
}>();
</script>
<template>
<div :class="`callout callout-${type}`">
<slot />
</div>
</template>
Then use it in any markdown file:
<!-- content/blog/third-post.md -->
Here is some regular markdown.
::callout{type="tip"}
You can use Vue components directly inside markdown files.
::
Back to regular markdown.
The ::component-name{props} syntax is part of MDC (Markdown Components), which @nuxt/content supports out of the box.
@nuxt/content turns markdown files in the content/ directory into a queryable data layer<ContentDoc> handles single-page rendering, while queryContent() gives you full query controluseAsyncData for proper SSR and hydration.only() when listing content to keep payloads smallfetchContentNavigation() auto-generates navigation from your directory structureWe covered the core workflow for handling content in Nuxt: rendering single pages, building queryable lists with filtering, generating navigation, and embedding Vue components in markdown. The module does a lot of the heavy lifting so you can focus on the content itself.
I hope this post has been helpful. Please share any questions or comments. Happy coding!
Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.

Handling Content in Nuxt
A practical walkthrough of @nuxt/content for querying, rendering, and navigating markdown-based content in Nuxt.
Reza Baar
Apr 8, 2026

How to Pick the Right Dependencies for Your Angular Application
Choosing the right libraries/dependencies for your Angular applications can make or break your project in the long run. Learn how to pick the right dependencies.
Alain Chautard
Apr 7, 2026

Accessible Vue Apps
Build more accessible Vue apps with practical guidance on semantic HTML, ARIA bindings, focus management, accessible routing, keyboard navigation, and common mistakes to avoid.
Reza Baar
Mar 31, 2026
We can help you recruit Certified Developers for your organization or project. The team has helped many customers employ suitable resources from a pool of 100s of qualified Developers.
Let us help you get the resources you need.
