Handling Content in Nuxt

Handling Content in Nuxt

A practical walkthrough of @nuxt/content for querying, rendering, and navigating markdown-based content in Nuxt.

Reza Baar

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.

Setting Things Up

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.

Rendering a Single Page

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.

Customizing the Rendered Output

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.

Querying Content with queryContent()

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.

Adding Filters

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.

Selecting Only What You Need

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.

Building Content Navigation

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.

Using Components Inside Markdown

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.

Key Takeaways

  • @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 control
  • Always wrap content queries in useAsyncData for proper SSR and hydration
  • Use .only() when listing content to keep payloads small
  • fetchContentNavigation() auto-generates navigation from your directory structure
  • MDC syntax lets you embed Vue components directly in markdown

Conclusion

We 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!

More certificates.dev articles

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.

Looking for Certified Developers?

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.

Contact Us
Customer Testimonial for Hiring
like a breath of fresh air
Everett Owyoung
Everett Owyoung
Head of Talent for ThousandEyes
(a Cisco company)