| name | nuxt-content |
| description | Expert knowledge for @nuxt/content module in Nuxt 4. Activate when working with content directory, markdown files, frontmatter, or ContentRenderer. |
Nuxt Content Expertise (Nuxt 4)
Activation Triggers
- Creating/editing files in
content/directory - Working with markdown frontmatter
- Using
queryContent()composable - Rendering content with
<ContentRenderer> - Building navigation from content
Nuxt 4 Specifics
Nuxt 4 uses the app/ directory structure:
project/
├── app/
│ ├── components/
│ ├── pages/
│ └── ...
├── content/ # Content stays at root level
│ └── ...
└── nuxt.config.ts
Content Directory Structure
content/
├── 1.phase-1-sdlc/
│ ├── _dir.yml # Directory metadata (optional)
│ ├── 1.sdlc-models/
│ │ ├── _dir.yml
│ │ ├── 1.waterfall-model.md
│ │ ├── 2.agile-methodology.md
│ │ └── 3.scrum-framework.md
│ └── 2.sdlc-phases/
│ └── ...
└── 2.phase-2-foundations/
└── ...
Naming Convention:
- Numeric prefixes (1., 2.) control ordering
- Prefixes are stripped from URLs
- Use kebab-case for slugs
Frontmatter Schema
---
title: "Lesson Title"
description: "Brief description for SEO and previews"
estimatedMinutes: 15
difficulty: beginner | intermediate | advanced
learningObjectives:
- "Objective 1"
- "Objective 2"
quiz:
passingScore: 70
questions:
- question: "Question text"
type: single | multiple | true-false
options: ["A", "B", "C", "D"]
correctAnswer: "A"
explanation: "Why this is correct"
---
# Content starts here
Nuxt Config
// nuxt.config.ts
export default defineNuxtConfig({
compatibilityVersion: 4,
modules: ['@nuxt/content', '@nuxt/ui'],
content: {
highlight: {
theme: 'github-dark',
langs: ['bash', 'typescript', 'javascript', 'python', 'yaml', 'dockerfile', 'json', 'sql']
},
markdown: {
toc: {
depth: 3,
searchDepth: 3
}
}
}
})
Querying Content
Get Single Document
const route = useRoute()
// Using path from route
const { data: lesson } = await useAsyncData(
`lesson-${route.path}`,
() => queryContent(route.path).findOne()
)
// Explicit path
const { data: lesson } = await useAsyncData('waterfall', () =>
queryContent('phase-1-sdlc/sdlc-models/waterfall-model').findOne()
)
Get All Documents in Directory
const { data: lessons } = await useAsyncData('sdlc-lessons', () =>
queryContent('phase-1-sdlc/sdlc-models')
.where({ _extension: 'md' })
.sort({ _path: 1 })
.find()
)
Get Navigation Tree
const { data: navigation } = await useAsyncData('navigation', () =>
fetchContentNavigation()
)
// Or for specific path
const { data: phaseNav } = await useAsyncData('phase-nav', () =>
fetchContentNavigation(queryContent('phase-1-sdlc'))
)
Previous/Next Navigation
const { data: surround } = await useAsyncData('surround', () =>
queryContent()
.only(['_path', 'title'])
.sort({ _path: 1 })
.findSurround(route.path)
)
const [prev, next] = surround.value || [null, null]
Query with Filters
// By difficulty
const { data: beginnerLessons } = await useAsyncData('beginner', () =>
queryContent()
.where({ difficulty: 'beginner' })
.find()
)
// By field existence
const { data: withQuiz } = await useAsyncData('with-quiz', () =>
queryContent()
.where({ 'quiz': { $exists: true } })
.find()
)
// Count documents
const count = await queryContent('phase-1-sdlc').count()
Rendering Content
Basic Rendering
<template>
<div v-if="lesson" class="prose prose-invert">
<ContentRenderer :value="lesson" />
</div>
</template>
With ContentDoc Component
<template>
<ContentDoc :path="path">
<template #default="{ doc }">
<article>
<h1>{{ doc.title }}</h1>
<div class="prose prose-invert">
<ContentRenderer :value="doc" />
</div>
</article>
</template>
<template #not-found>
<div>Lesson not found</div>
</template>
<template #empty>
<div>No content available</div>
</template>
</ContentDoc>
</template>
Table of Contents
<template>
<nav v-if="lesson?.body?.toc?.links">
<ul>
<li v-for="link in lesson.body.toc.links" :key="link.id">
<a :href="`#${link.id}`">{{ link.text }}</a>
<ul v-if="link.children">
<li v-for="child in link.children" :key="child.id">
<a :href="`#${child.id}`">{{ child.text }}</a>
</li>
</ul>
</li>
</ul>
</nav>
</template>
Prose Styling
Use Tailwind Typography for content styling:
<div class="prose prose-invert prose-lg max-w-none">
<ContentRenderer :value="lesson" />
</div>
Customize prose in Tailwind config if needed:
// tailwind.config.js
module.exports = {
theme: {
extend: {
typography: {
invert: {
css: {
'--tw-prose-body': 'var(--color-gray-300)',
'--tw-prose-headings': 'var(--color-gray-100)',
// ... more customizations
}
}
}
}
}
}
Common Patterns
Loading State
<template>
<div v-if="pending">
<USkeleton class="h-8 w-64 mb-4" />
<USkeleton class="h-4 w-full mb-2" />
<USkeleton class="h-4 w-3/4" />
</div>
<div v-else-if="error">
<p>Error loading content</p>
</div>
<div v-else-if="lesson">
<ContentRenderer :value="lesson" />
</div>
</template>
Dynamic Routes
app/pages/[phase]/[topic]/[subtopic].vue
const route = useRoute()
const { phase, topic, subtopic } = route.params as {
phase: string
topic: string
subtopic: string
}
const contentPath = `${phase}/${topic}/${subtopic}`
Key Differences from Nuxt 3
- App directory is
app/not root - Use
compatibilityVersion: 4in config - Content module works the same way
- Query syntax unchanged
- ContentRenderer unchanged
Gotchas
- Use
_pathnotpathfor internal content paths - Numeric prefixes are stripped from URLs (1.topic becomes /topic)
- Use
_dir.ymlfor directory-level metadata - Always use
useAsyncDatafor SSR compatibility - The
$existsfilter checks if a field exists