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

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

  1. queryContent()queryCollection(name)

    // 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


Note: This reference covers Nuxt Content v3. The v2 API (queryContent, ContentDoc, ContentList) is deprecated and not compatible with v3.