Skip to main content

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

terminal
pnpm remove @nuxt/content
pnpm add @lupinum/ginko-content@0.3.0 zod

Register only Ginko in nuxt.config.ts:

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:

content.config.ts
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:

app/pages/[...slug].vue
<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 v2Ginko
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:

ts
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 v2Ginko
Nuxt Content v2 route and identity
_pathroute.resolvedPath for links; route.requestedPath for the path used to resolve this result
_idid
_localelocale
Nuxt Content v2 file provenance
_sourcefile.source when filesystem provenance exists
_filefile.path when filesystem provenance exists
_dirfile.dir
Nuxt Content v2 file names
_stemstem
_basenamefile.basename
_extensionextension
Nuxt Content v2 status and type
_typetype
_draftdraft
_partialpartial
tocbody.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:

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

terminal
pnpm lint
pnpm typecheck
pnpm build
pnpm preview

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