17 KiB
Nuxt Content Reference
Last Updated: 2025-11 (v3.8.0)
Check: @nuxt/content in package.json
Nuxt Content v3 is a Git-based CMS that uses SQL-backed collections for querying markdown, YAML, JSON, and CSV files. Perfect for blogs, documentation sites, and content-heavy applications.
Installation & Setup
pnpm add @nuxt/content
nuxt.config.ts:
export default defineNuxtConfig({
modules: ["@nuxt/content"],
content: {
// Optional configuration
highlight: {
theme: "github-dark",
preload: ["typescript", "vue", "bash"],
},
},
})
Collections
Defining Collections
Create content.config.ts in your project root:
import { defineCollection, defineContentConfig, z } from "@nuxt/content"
export default defineContentConfig({
collections: {
blog: defineCollection({
type: "page",
source: "blog/**/*.md",
schema: z.object({
title: z.string(),
description: z.string(),
date: z.string(),
tags: z.array(z.string()).optional(),
author: z.string().optional(),
image: z.string().optional(),
draft: z.boolean().default(false),
}),
}),
docs: defineCollection({
type: "page",
source: "docs/**/*.md",
schema: z.object({
title: z.string(),
description: z.string(),
category: z.string().optional(),
}),
}),
},
})
Directory Structure
content/
├── blog/
│ ├── post-1.md
│ ├── post-2.md
│ └── post-3.md
└── docs/
├── getting-started.md
└── api/
└── components.md
Frontmatter
Add metadata to content files:
---
title: "My Blog Post"
description: "A brief description"
date: "2025-01-04"
tags: ["nuxt", "vue", "web-dev"]
author: "John Doe"
image: "/images/cover.jpg"
draft: false
---
# Content starts here
Your markdown content...
Querying Collections
queryCollection Composable
The main API for querying content in v3:
<script setup lang="ts">
// Get all articles from 'blog' collection
const { data: articles } = await useAsyncData("articles", () =>
queryCollection("blog").all(),
)
// Get single article by path
const route = useRoute()
const { data: article } = await useAsyncData("article", () =>
queryCollection("blog").path(route.path).first(),
)
</script>
<template>
<div>
<h1>{{ article?.title }}</h1>
<ContentRenderer v-if="article" :value="article" />
</div>
</template>
Query Methods
all()
Get all matching documents:
const posts = await queryCollection("blog").all()
first()
Get first matching document:
const post = await queryCollection("blog").path("/blog/my-post").first()
where()
Filter by field with SQL operators:
// Single condition
const published = await queryCollection("blog").where("draft", "=", false).all()
// Multiple conditions (AND)
const filtered = await queryCollection("blog")
.where("draft", "=", false)
.where("category", "=", "tech")
.all()
andWhere()
Complex AND conditions:
const posts = await queryCollection("blog")
.where("published", "=", true)
.andWhere((query) =>
query.where("date", ">", "2024-01-01").where("category", "=", "news"),
)
.all()
orWhere()
OR conditions:
const posts = await queryCollection("blog")
.where("published", "=", true)
.orWhere((query) =>
query.where("featured", "=", true).where("priority", ">", 5),
)
.all()
order()
Sort results:
// Descending
const posts = await queryCollection("blog").order("date", "DESC").all()
// Ascending
const posts = await queryCollection("blog").order("title", "ASC").all()
limit()
Limit results:
const latest = await queryCollection("blog")
.order("date", "DESC")
.limit(5)
.all()
skip()
Skip results (for pagination):
const page = 2
const perPage = 10
const posts = await queryCollection("blog")
.order("date", "DESC")
.skip((page - 1) * perPage)
.limit(perPage)
.all()
select()
Select specific fields:
const posts = await queryCollection("blog")
.select(["title", "description", "date", "path"])
.all()
path()
Filter by path:
const post = await queryCollection("blog").path("/blog/my-post").first()
ContentRenderer Component
Renders parsed markdown content (the main component for v3):
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useAsyncData("post", () =>
queryCollection("blog").path(route.path).first(),
)
</script>
<template>
<article v-if="post" class="prose dark:prose-invert">
<h1>{{ post.title }}</h1>
<ContentRenderer :value="post" />
</article>
</template>
With error handling:
<script setup lang="ts">
const route = useRoute()
const { data: post, error } = await useAsyncData("post", () =>
queryCollection("blog").path(route.path).first(),
)
if (error.value || !post.value) {
throw createError({
statusCode: 404,
message: "Post not found",
})
}
</script>
<template>
<ContentRenderer :value="post" />
</template>
Common Patterns
Blog List Page
<script setup lang="ts">
const { data: articles } = await useAsyncData("articles", () =>
queryCollection("blog")
.where("draft", "=", false)
.order("date", "DESC")
.all(),
)
</script>
<template>
<div>
<h1>Blog</h1>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<article v-for="article of articles" :key="article.path" class="card">
<NuxtImg
v-if="article.image"
:src="article.image"
:alt="article.title"
class="w-full h-48 object-cover"
/>
<div class="p-4">
<h2 class="text-xl font-bold">{{ article.title }}</h2>
<p class="text-gray-600">{{ article.description }}</p>
<div class="flex items-center gap-2 mt-2 text-sm text-gray-500">
<span>{{ article.author }}</span>
<span>•</span>
<time>{{ article.date }}</time>
</div>
<NuxtLink
:to="article.path"
class="text-blue-500 hover:underline mt-2 inline-block"
>
Read more →
</NuxtLink>
</div>
</article>
</div>
</div>
</template>
Single Blog Post with Related Articles
<script setup lang="ts">
const route = useRoute()
const { data: article } = await useAsyncData("article", () =>
queryCollection("blog").path(route.path).first(),
)
if (!article.value) {
throw createError({
statusCode: 404,
message: "Article not found",
})
}
// Get related articles by tags
const { data: related } = await useAsyncData("related", async () => {
if (!article.value?.tags?.length) return []
const all = await queryCollection("blog").where("draft", "=", false).all()
// Filter by matching tags
return all
.filter(
(post) =>
post.path !== route.path &&
post.tags?.some((tag) => article.value.tags.includes(tag)),
)
.slice(0, 3)
})
useSeoMeta({
title: article.value.title,
description: article.value.description,
ogImage: article.value.image,
})
</script>
<template>
<article class="prose dark:prose-invert mx-auto">
<h1>{{ article.title }}</h1>
<div class="flex items-center gap-4 text-gray-600 mb-8">
<span>{{ article.author }}</span>
<time>{{ article.date }}</time>
<div v-if="article.tags" class="flex gap-2">
<span v-for="tag of article.tags" :key="tag" class="badge">
{{ tag }}
</span>
</div>
</div>
<ContentRenderer :value="article" />
<div v-if="related?.length" class="mt-12">
<h2>Related Articles</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="post of related" :key="post.path">
<NuxtLink :to="post.path">{{ post.title }}</NuxtLink>
</div>
</div>
</div>
</article>
</template>
Documentation Site
<!-- pages/docs/[...slug].vue -->
<script setup lang="ts">
const route = useRoute()
const slug = (route.params.slug as string[])?.join("/") || "index"
const { data: doc } = await useAsyncData(`doc-${slug}`, () =>
queryCollection("docs").path(`/docs/${slug}`).first(),
)
if (!doc.value) {
throw createError({ statusCode: 404, message: "Documentation not found" })
}
// Get navigation
const { data: navigation } = await useAsyncData("docs-nav", () =>
queryCollectionNavigation("docs"),
)
useSeoMeta({
title: doc.value.title,
description: doc.value.description,
})
</script>
<template>
<div class="flex">
<!-- Sidebar navigation -->
<aside class="w-64 pr-8">
<nav>
<ul>
<li v-for="link of navigation" :key="link.path">
<NuxtLink :to="link.path">{{ link.title }}</NuxtLink>
</li>
</ul>
</nav>
</aside>
<!-- Main content -->
<main class="flex-1">
<article class="prose dark:prose-invert">
<h1>{{ doc.title }}</h1>
<ContentRenderer :value="doc" />
</article>
</main>
</div>
</template>
Search
<script setup lang="ts">
const searchQuery = ref("")
const { data: allPosts } = await useAsyncData("all-posts", () =>
queryCollection("blog").all(),
)
const results = computed(() => {
if (!searchQuery.value) return []
const query = searchQuery.value.toLowerCase()
return (
allPosts.value
?.filter(
(post) =>
post.title?.toLowerCase().includes(query) ||
post.description?.toLowerCase().includes(query),
)
.slice(0, 10) || []
)
})
</script>
<template>
<div>
<input
v-model="searchQuery"
type="search"
placeholder="Search articles..."
class="w-full px-4 py-2 border rounded"
/>
<div v-if="results.length" class="mt-4">
<div v-for="result of results" :key="result.path" class="mb-4">
<NuxtLink :to="result.path" class="text-lg font-bold">
{{ result.title }}
</NuxtLink>
<p class="text-gray-600">{{ result.description }}</p>
</div>
</div>
</div>
</template>
Pagination
<script setup lang="ts">
const route = useRoute()
const page = computed(() => parseInt(route.query.page as string) || 1)
const perPage = 10
const { data: posts } = await useAsyncData(
`posts-page-${page.value}`,
() =>
queryCollection("blog")
.where("draft", "=", false)
.order("date", "DESC")
.skip((page.value - 1) * perPage)
.limit(perPage)
.all(),
{
watch: [page],
},
)
// Get total count for pagination
const { data: allPosts } = await useAsyncData("all-posts", () =>
queryCollection("blog").where("draft", "=", false).all(),
)
const totalPages = computed(() =>
Math.ceil((allPosts.value?.length || 0) / perPage),
)
</script>
<template>
<div>
<div class="grid gap-6">
<article v-for="post of posts" :key="post.path">
<!-- Article card -->
</article>
</div>
<div class="flex justify-center gap-2 mt-8">
<NuxtLink v-if="page > 1" :to="{ query: { page: page - 1 } }" class="btn">
Previous
</NuxtLink>
<span>Page {{ page }} of {{ totalPages }}</span>
<NuxtLink
v-if="page < totalPages"
:to="{ query: { page: page + 1 } }"
class="btn"
>
Next
</NuxtLink>
</div>
</div>
</template>
Filter by Tags/Categories
<script setup lang="ts">
const selectedTag = ref<string | null>(null)
// Get all posts to extract unique tags
const { data: allPosts } = await useAsyncData("all-posts", () =>
queryCollection("blog").where("draft", "=", false).all(),
)
const tags = computed(() => {
const tagSet = new Set<string>()
allPosts.value?.forEach((post) => {
post.tags?.forEach((tag: string) => tagSet.add(tag))
})
return Array.from(tagSet).sort()
})
// Filter posts by selected tag
const filteredPosts = computed(() => {
if (!selectedTag.value) return allPosts.value
return allPosts.value?.filter((post) =>
post.tags?.includes(selectedTag.value!),
)
})
</script>
<template>
<div>
<div class="flex gap-2 mb-6">
<button :class="{ active: !selectedTag }" @click="selectedTag = null">
All
</button>
<button
v-for="tag of tags"
:key="tag"
:class="{ active: selectedTag === tag }"
@click="selectedTag = tag"
>
{{ tag }}
</button>
</div>
<div class="grid gap-6">
<article v-for="post of filteredPosts" :key="post.path">
<h2>{{ post.title }}</h2>
<p>{{ post.description }}</p>
<NuxtLink :to="post.path">Read more</NuxtLink>
</article>
</div>
</div>
</template>
Server-Side Queries
Use queryCollection in API routes with event parameter:
// server/api/posts.get.ts
export default defineEventHandler(async (event) => {
const posts = await queryCollection(event, "blog")
.where("draft", "=", false)
.order("date", "DESC")
.limit(10)
.all()
return posts
})
With query parameters:
// server/api/posts/[slug].get.ts
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, "slug")
const post = await queryCollection(event, "blog")
.path(`/blog/${slug}`)
.first()
if (!post) {
throw createError({
statusCode: 404,
message: "Post not found",
})
}
return post
})
Navigation
Use queryCollectionNavigation for generating navigation:
<script setup lang="ts">
const { data: navigation } = await useAsyncData("navigation", () =>
queryCollectionNavigation("docs"),
)
</script>
<template>
<nav>
<ul>
<li v-for="item of navigation" :key="item.path">
<NuxtLink :to="item.path">{{ item.title }}</NuxtLink>
<ul v-if="item.children">
<li v-for="child of item.children" :key="child.path">
<NuxtLink :to="child.path">{{ child.title }}</NuxtLink>
</li>
</ul>
</li>
</ul>
</nav>
</template>
Markdown Features
Syntax Highlighting
Configure in nuxt.config.ts:
export default defineNuxtConfig({
content: {
highlight: {
theme: {
default: "github-light",
dark: "github-dark",
},
preload: ["typescript", "vue", "bash", "json"],
},
},
})
Use in markdown:
```typescript
interface User {
name: string
age: number
}
```
Custom Vue Components in Markdown
Use Vue components directly in markdown (MDC syntax):
# My Article
::Alert{type="info"}
This is an informational alert!
::
::CallToAction{title="Get Started" url="/docs"}
Learn more about Nuxt Content
::
Register components in components/content/ directory:
<!-- components/content/Alert.vue -->
<script setup lang="ts">
defineProps<{
type?: "info" | "warning" | "error"
}>()
</script>
<template>
<div :class="`alert alert-${type}`">
<slot />
</div>
</template>
TypeScript Support
Type your collections:
// types/content.ts
export interface BlogPost {
title: string
description: string
date: string
tags?: string[]
author?: string
image?: string
draft: boolean
path: string
body: any
}
Use in components:
<script setup lang="ts">
import type { BlogPost } from "~/types/content"
const { data: posts } = await useAsyncData("posts", () =>
queryCollection<BlogPost>("blog").all(),
)
</script>
Migration from v2
Key Changes
-
queryContent()→queryCollection(name)// v2 queryContent("/blog").find() // v3 queryCollection("blog").all() -
Collections must be defined in
content.config.ts -
Components removed:
- ❌
<ContentDoc>- use ContentRenderer - ❌
<ContentList>- query manually with queryCollection - ❌
<ContentNavigation>- use queryCollectionNavigation - ❌
<ContentQuery>- use queryCollection
- ❌
-
New query methods:
.all()instead of.find().first()instead of.findOne().where()with SQL operators.order()instead of.sort()
-
SQL-backed storage - faster queries for large datasets
Best Practices
- Define collections - Always create
content.config.tswith schemas - Use TypeScript - Type your collections for better DX
- Cache queries - Use
useAsyncDatawith proper keys - Server-side queries - Query on server for API routes
- Index for performance - Consider indexing frequently queried fields
- Validate frontmatter - Use Zod schemas in collection definitions
- Handle 404s - Always check if content exists and throw errors
- Use path() - More efficient than where() for path filtering
- Select fields - Use
.select()to reduce payload size - Pagination - Implement for large collections
Official Resources
- Documentation: https://content.nuxt.com
- Collections: https://content.nuxt.com/docs/collections
- Query API: https://content.nuxt.com/docs/utils/query-collection
- Migration Guide: https://content.nuxt.com/docs/getting-started/migration
- GitHub: https://github.com/nuxt/content
Note: This reference covers Nuxt Content v3. The v2 API (queryContent, ContentDoc, ContentList) is deprecated and not compatible with v3.