From Nuxt Content v3
Replace Nuxt Content v3 queries and runtime behavior while keeping collection-based authoring.
Nuxt Content v3 and Ginko share content.config.ts, page and data collections, and <ContentRenderer>. Their query APIs and runtime implementations are different: Nuxt Content v3 uses queryCollection() and a SQL-backed runtime; Ginko uses collection handles with one(), many(), and useContentPage().
If the application still uses queryContent() or <ContentDoc>, follow From Nuxt Content v2 instead.
Replace the package and module
pnpm remove @nuxt/content
pnpm add @lupinum/ginko-content@0.3.0 zodRegister only Ginko:
export default defineNuxtConfig({
modules: ['@lupinum/ginko-content']
})Do not run both modules in one application. They register overlapping components, auto-imports, runtime configuration, and content routes.
Remove Nuxt Content settings tied to its database and build pipeline, including content.database, content.build, and content.renderer. Ginko does not use the client SQLite/WASM query runtime. Its Markdown options live under content.markdown.
content.preview also has different semantics. In Ginko it protects development preview-storage overlays; ordinary drafts are already visible in development. The production filesystem snapshot is sealed, so authenticated production preview requires an external provider that owns authentication and unpublished content selection.
If removed packages still appear in the lockfile, inspect their owners before deleting them:
pnpm why @nuxt/content better-sqlite3 @standard-schema/specUpdate collection imports
The collection shape remains object-based. Change the helper import, import Zod directly, and export handles used by application code:
import { defineCollection, defineContentConfig } from '@lupinum/ginko-content/config'
import { z } from 'zod'
export const docs = defineCollection({
type: 'page',
source: {
include: 'docs/**/*.md',
exclude: ['docs/private/**']
},
schema: z.object({
title: z.string(),
description: z.string().optional()
})
})
export const authors = defineCollection({
type: 'data',
source: 'authors/**/*.yml',
schema: z.object({
name: z.string(),
avatar: z.string().url().optional()
})
})
export default defineContentConfig({
collections: { docs, authors }
})| Nuxt Content v3 | Ginko |
|---|---|
defineCollection({ type, source, schema }) | Same declaration shape |
Key in collections | Canonical collection name |
type: 'page' | Route-backed collection |
type: 'data' | Data collection; creates no page routes and defaults to sitemap: false |
source.include / source.exclude | Supported |
Helpers from @nuxt/content | Helpers from @lupinum/ginko-content/config |
Zod re-export from @nuxt/content | z from zod |
Studio .editor(...) field metadata | Remove it or keep editor metadata outside the runtime schema |
Folder metadata remains .navigation.yml. Keep type: 'data' for existing data collections unless they should become public pages.
Replace route queries
Nuxt Content v3 route pages usually query by route.path:
const { data: page } = await useAsyncData(route.path, () => {
return queryCollection('docs').path(route.path).first()
})In Ginko, useContentPage() owns current-route resolution. The page component still owns its 404 and metadata policy:
<script setup lang="ts">
import { createError, useSeoMeta } from '#imports'
import { docs } from '~~/content.config'
definePageMeta({ key: route => route.path })
const { page, previous, next } = await useContentPage(docs, {
surround: { select: ['title', 'description'] }
})
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" />
<nav v-if="previous || next">
<NuxtLink v-if="previous" :to="previous.path">
{{ previous.title }}
</NuxtLink>
<NuxtLink v-if="next" :to="next.path">
{{ next.title }}
</NuxtLink>
</nav>
</template>Pass the complete document to <ContentRenderer>, not page.body. Add a Nuxt page component for every collection route pattern that should render publicly, such as /blog/[slug].vue or /changelog/[slug].vue.
Replace list queries
Import query functions from @lupinum/ginko-content/client and pair them with useAsyncData in Vue components:
import { many } from '@lupinum/ginko-content/client'
import { docs } from '~~/content.config'
const { data: pages } = await useAsyncData(
'docs:list',
() => many(docs, {
where: { draft: { $ne: true } },
sort: { title: 'asc' },
select: ['title', 'description'],
limit: 20
})
)Use page.route.resolvedPath for links. The route and identity fields remain available even when select narrows authored fields.
Common query replacements:
| Nuxt Content v3 | Ginko |
|---|---|
| Nuxt Content v3 reads and filters | |
queryCollection('blog').all() | many(blog) |
.path(route.path).first() | useContentPage(blog) in a route page, or one(blog, { by: { route: route.path } }) elsewhere |
.where('published', '=', true) | many(blog, { where: { published: true } }) |
| Nuxt Content v3 ordering and projection | |
.order('date', 'DESC') | many(blog, { sort: { date: 'desc' } }) |
.select('title', 'description') | many(blog, { select: ['title', 'description'] }) |
.skip(20).limit(10).all() | many(blog, { skip: 20, limit: 10 }) |
| Nuxt Content v3 navigation and search | |
queryCollectionItemSurroundings('docs', path) | useContentPage(docs, { surround: true }) in a route page, or surround(docs, { by: { route: path } }) |
queryCollectionNavigation('docs') | navigation(docs) |
queryCollectionSearchSections('docs') | useContentSearch({ collection: docs }) |
Ginko filters use JSON-safe operators rather than SQL operators. For example, replace LIKE '/blog/%' with { path: { $prefix: '/blog/' } }. The query operators page lists the supported grammar.
Server queries use the same verbs from @lupinum/ginko-content/server, with the H3 event as the first argument: many(event, blog, options).
Update document fields
Most authored fields and Markdown body data remain unchanged. Route facts move into a dedicated envelope, while undeclared v3 metadata needs an explicit Ginko schema field.
| Nuxt Content v3 | Ginko |
|---|---|
page.path | page.route.resolvedPath |
page.id | page.id |
page.stem | page.stem |
page.extension | page.extension |
page.meta.foo for undeclared frontmatter | Declare foo in the collection schema and read page.foo |
page.title / page.description | Same fields |
page.seo | Same conventional SEO field |
page.body | Same parsed body field; pass the whole page to <ContentRenderer> |
For locale-aware content, read page.locale, page.route.alternates, and page.resolution. Do not recreate paths from id, stem, file, or canonicalKey.
Replace navigation, surroundings, and search
Search is disabled by default. Set content.search: {} in nuxt.config.ts to enable MiniSearch, or configure Pagefind or provider search. Load application navigation separately from search navigation:
import { navigation, useContentSearch } from '@lupinum/ginko-content/client'
import { docs } from '~~/content.config'
const { data: docsNavigation } = await useAsyncData(
'docs:navigation',
() => navigation(docs, { select: ['description', 'icon'] })
)
const {
query,
files,
searchNavigation
} = await useContentSearch({ collection: docs })searchNavigation is shaped for search UI such as Nuxt UI's UContentSearch; it is not a replacement for the site's navigation tree. Keep the useContentSearch() call outside <ClientOnly> when generated pages need to serialize its files and navigation payload.
Move Markdown and sitemap settings
Move Nuxt Content v3 content.renderer and content.build.markdown settings to Ginko's content.markdown. Ginko uses Comark plugins, so adapt Remark or Rehype plugins instead of copying them unchanged.
export default defineNuxtConfig({
modules: ['@lupinum/ginko-content'],
content: {
search: {},
markdown: {
plugins: [
'toc',
'shiki'
],
tags: {
code: 'ProseCodeInline',
pre: 'ProsePre'
}
}
}
})When the site publishes content routes, install @nuxtjs/sitemap and enable a build assertion with production-shaped expectations:
pnpm add @nuxtjs/sitemapexport default defineNuxtConfig({
modules: ['@lupinum/ginko-content', '@nuxtjs/sitemap'],
site: {
url: 'https://docs.example.com'
},
content: {
sitemap: {
assert: {
enabled: true,
mode: 'both',
minUrlsPerSitemap: 10,
requiredCollections: ['docs', 'blog']
}
}
}
})Set minUrlsPerSitemap to a useful lower bound for the real site. After building, open the generated sitemap URL and confirm representative docs and blog URLs appear, then direct-load those URLs in the production preview. A passing count alone does not prove that the correct routes shipped.
Scan for stale APIs
Search application source and configuration for old Nuxt Content v3 APIs and runtime settings:
rg "@nuxt/content|queryCollection|queryCollectionNavigation|queryCollectionItemSurroundings|queryCollectionSearchSections|content\.(database|build|renderer)|better-sqlite3|standard-schema|\.editor\("Review lockfile matches with pnpm why instead of deleting transitive packages blindly.
Verify the migration
Run the application's production checks:
pnpm lint
pnpm typecheck
pnpm build
pnpm previewIn the preview, verify:
- the home page, a nested content page, and a missing URL by direct navigation;
- list links, previous/next links, and navigation paths;
- search results and their destination paths;
- draft and partial visibility;
- locale routes, alternates, and fallback when i18n is enabled;
- representative URLs in the generated sitemap, with no prerender 404s.