Skip to main content

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

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

Register only Ginko:

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

terminal
pnpm why @nuxt/content better-sqlite3 @standard-schema/spec

Update collection imports

The collection shape remains object-based. Change the helper import, import Zod directly, and export handles used by application code:

content.config.ts
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 v3Ginko
defineCollection({ type, source, schema })Same declaration shape
Key in collectionsCanonical collection name
type: 'page'Route-backed collection
type: 'data'Data collection; creates no page routes and defaults to sitemap: false
source.include / source.excludeSupported
Helpers from @nuxt/contentHelpers from @lupinum/ginko-content/config
Zod re-export from @nuxt/contentz from zod
Studio .editor(...) field metadataRemove 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:

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

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

ts
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 v3Ginko
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 v3Ginko
page.pathpage.route.resolvedPath
page.idpage.id
page.stempage.stem
page.extensionpage.extension
page.meta.foo for undeclared frontmatterDeclare foo in the collection schema and read page.foo
page.title / page.descriptionSame fields
page.seoSame conventional SEO field
page.bodySame 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.

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:

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

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

terminal
pnpm add @nuxtjs/sitemap
nuxt.config.ts
export 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:

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

terminal
pnpm lint
pnpm typecheck
pnpm build
pnpm preview

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