Files
gh-lttr-claude-marketplace-…/skills/nuxt/references/nuxt-content.md
2025-11-30 08:38:06 +08:00

828 lines
17 KiB
Markdown

# 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
```bash
pnpm add @nuxt/content
```
**nuxt.config.ts:**
```typescript
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:
```typescript
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:
```markdown
---
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:
```vue
<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:
```typescript
const posts = await queryCollection("blog").all()
```
#### first()
Get first matching document:
```typescript
const post = await queryCollection("blog").path("/blog/my-post").first()
```
#### where()
Filter by field with SQL operators:
```typescript
// 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:
```typescript
const posts = await queryCollection("blog")
.where("published", "=", true)
.andWhere((query) =>
query.where("date", ">", "2024-01-01").where("category", "=", "news"),
)
.all()
```
#### orWhere()
OR conditions:
```typescript
const posts = await queryCollection("blog")
.where("published", "=", true)
.orWhere((query) =>
query.where("featured", "=", true).where("priority", ">", 5),
)
.all()
```
#### order()
Sort results:
```typescript
// Descending
const posts = await queryCollection("blog").order("date", "DESC").all()
// Ascending
const posts = await queryCollection("blog").order("title", "ASC").all()
```
#### limit()
Limit results:
```typescript
const latest = await queryCollection("blog")
.order("date", "DESC")
.limit(5)
.all()
```
#### skip()
Skip results (for pagination):
```typescript
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:
```typescript
const posts = await queryCollection("blog")
.select(["title", "description", "date", "path"])
.all()
```
#### path()
Filter by path:
```typescript
const post = await queryCollection("blog").path("/blog/my-post").first()
```
## ContentRenderer Component
Renders parsed markdown content (the main component for v3):
```vue
<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:
```vue
<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
```vue
<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
```vue
<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
```vue
<!-- 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
```vue
<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
```vue
<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
```vue
<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:
```typescript
// 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:
```typescript
// 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:
```vue
<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`:
```typescript
export default defineNuxtConfig({
content: {
highlight: {
theme: {
default: "github-light",
dark: "github-dark",
},
preload: ["typescript", "vue", "bash", "json"],
},
},
})
```
Use in markdown:
````markdown
```typescript
interface User {
name: string
age: number
}
```
````
### Custom Vue Components in Markdown
Use Vue components directly in markdown (MDC syntax):
```markdown
# 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:
```vue
<!-- 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:
```typescript
// 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:
```vue
<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
1. **`queryContent()` → `queryCollection(name)`**
```typescript
// v2
queryContent("/blog").find()
// v3
queryCollection("blog").all()
```
2. **Collections must be defined in `content.config.ts`**
3. **Components removed:**
- ❌ `<ContentDoc>` - use ContentRenderer
- ❌ `<ContentList>` - query manually with queryCollection
- ❌ `<ContentNavigation>` - use queryCollectionNavigation
- ❌ `<ContentQuery>` - use queryCollection
4. **New query methods:**
- `.all()` instead of `.find()`
- `.first()` instead of `.findOne()`
- `.where()` with SQL operators
- `.order()` instead of `.sort()`
5. **SQL-backed storage** - faster queries for large datasets
## Best Practices
1. **Define collections** - Always create `content.config.ts` with schemas
2. **Use TypeScript** - Type your collections for better DX
3. **Cache queries** - Use `useAsyncData` with proper keys
4. **Server-side queries** - Query on server for API routes
5. **Index for performance** - Consider indexing frequently queried fields
6. **Validate frontmatter** - Use Zod schemas in collection definitions
7. **Handle 404s** - Always check if content exists and throw errors
8. **Use path()** - More efficient than where() for path filtering
9. **Select fields** - Use `.select()` to reduce payload size
10. **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.