From Nuxt Content v2
Replace Nuxt Content v2 document-driven pages and fluent queries with Ginko collections and explicit route pages.
Most Markdown files can stay where they are. The migration work is in the Nuxt module, collection configuration, queries, route pages, and document fields.
Ginko requires Nuxt 4.5.1 through Nuxt 4.x and Node.js 22.18–22.x, 24.11–24.x, or 26+. Complete that runtime upgrade before replacing the content module.
Replace the package and module
pnpm remove @nuxt/content
pnpm add @lupinum/ginko-content@0.3.0 zodRegister only Ginko in nuxt.config.ts:
export default defineNuxtConfig({
modules: ['@lupinum/ginko-content']
})Remove v2 options such as documentDriven. Collection match rules and schemas move to content.config.ts; Markdown, search, i18n, sitemap, and other runtime behavior remain under content in nuxt.config.ts. If the application uses extra storage mounts, keep them under content.sources and update them to Ginko's named mount shape.
Declare collections
Create content.config.ts at the application root. Start with one page collection that matches the files previously served by document-driven mode:
import { defineCollection, defineContentConfig } from '@lupinum/ginko-content/config'
import { z } from 'zod'
export const pages = defineCollection({
type: 'page',
source: '**/*.md',
schema: z.object({
title: z.string(),
description: z.string().optional(),
date: z.string().optional()
})
})
export default defineContentConfig({
collections: { pages }
})Split this collection only when different content needs a different schema, route mount, locale policy, or provider boundary. Use type: 'data' for content that should not create public routes. Rename Nuxt Content v2 _dir.yml files to .navigation.yml.
Replace document-driven pages
Ginko does not turn Markdown files into Nuxt pages on its own. Add a catch-all route and let useContentPage() resolve the current URL:
<script setup lang="ts">
import { createError, useSeoMeta } from '#imports'
import { pages } from '~~/content.config'
definePageMeta({ key: route => route.path })
const { page } = await useContentPage(pages)
if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
}
useSeoMeta({
title: () => page.value?.title,
description: () => page.value?.description
})
</script>
<template>
<ContentRenderer v-if="page" :value="page" />
</template>Replace <ContentDoc>, <ContentList>, <ContentQuery>, and <ContentNavigation> with explicit page queries and ordinary Vue templates. Pass the complete document to <ContentRenderer>, not page.body.
Replace fluent queries
Ginko queries use collection handles and one options object. Import client queries from @lupinum/ginko-content/client and pair them with useAsyncData in Vue components.
| Nuxt Content v2 | Ginko |
|---|---|
queryContent(route.path).findOne() | useContentPage(pages) in a route page, or one(pages, { by: { route: route.path } }) elsewhere |
.where({ published: true }) | many(posts, { where: { published: true } }) |
.where({ _path: /^\/blog\// }) | many(posts, { where: { path: { $prefix: '/blog/' } } }) |
.sort({ date: -1 }) | many(posts, { sort: { date: 'desc' } }) |
.only(['title', 'description']) | many(posts, { select: ['title', 'description'] }) |
.skip(20).limit(10).find() | many(posts, { skip: 20, limit: 10 }) |
.findSurround(path) | useContentPage(pages, { surround: true }) in a route page, or surround(pages, { by: { route: path } }) |
fetchContentNavigation() | navigation(pages) |
searchContent() | useContentSearch({ collection: pages }) |
Search is disabled by default. If the v2 site used searchContent(), set content.search: {} in nuxt.config.ts to enable MiniSearch, or configure another search engine.
For example, a date-sorted list becomes:
import { many } from '@lupinum/ginko-content/client'
import { pages } from '~~/content.config'
const { data: entries } = await useAsyncData(
'posts:latest',
() => many(pages, {
where: { draft: { $ne: true } },
sort: { date: 'desc' },
limit: 20
})
)Use entry.route.resolvedPath for links. The query API documents selectors, filters, projection, and pagination.
Update system fields
V2 exposed filesystem and route facts through underscore-prefixed fields. Ginko separates public routes from optional file provenance.
| Nuxt Content v2 | Ginko |
|---|---|
| Nuxt Content v2 route and identity | |
_path | route.resolvedPath for links; route.requestedPath for the path used to resolve this result |
_id | id |
_locale | locale |
| Nuxt Content v2 file provenance | |
_source | file.source when filesystem provenance exists |
_file | file.path when filesystem provenance exists |
_dir | file.dir |
| Nuxt Content v2 file names | |
_stem | stem |
_basename | file.basename |
_extension | extension |
| Nuxt Content v2 status and type | |
_type | type |
_draft | draft |
_partial | partial |
toc | body.toc |
Do not derive a public URL from file, id, or canonicalKey. Query results keep their link target in route.resolvedPath; navigation and search results expose a top-level path.
Scan for stale APIs
Search for old Nuxt Content v2 APIs and fields from the application root, then review every match:
rg "@nuxt/content|queryContent|fetchContentNavigation|searchContent|ContentDoc|ContentList|ContentQuery|ContentNavigation|documentDriven|_path|_id|_source|_file|_dir|_stem|_basename|_extension|_type|_draft|_partial"Lockfile-only matches may be transitive dependencies. Use pnpm why @nuxt/content before removing anything another package still needs.
Verify the production result
Run the application's real checks and preview the built output:
pnpm lint
pnpm typecheck
pnpm build
pnpm previewDirect-load the home page, a nested content page, and a missing URL. Confirm list links use the expected public paths, navigation and search return content, and drafts and partials stay out of production routes. If the site is static, also run pnpm generate and serve the generated directory over HTTP.