Nuxt Content v3 マイグレーションガイド

作成日: 2025-03-28 /

今年 2025 年の 1 月にメジャーバージョンの v3 がリリースされたので、メジャーバージョンの v2 を利用しているこのブログでも v3 へマイグレートしました。(参考: offich.me をささえる技術)

Announcing Nuxt Content version 3

Announcing Nuxt Content version 3

https://content.nuxt.com

Announcing Nuxt Content version 3

予想以上に詰まったことが多く、せっかくなら作業ログを残す形で、同じく現在 v2 を利用していて v3 へマイグレートする方へのヒントになるような記事を目指します。

Nuxt Content とは

そもそも Nuxt Content とは、Nuxt 用のヘッドレス CMS ライクなコンテンツ管理モジュールです。Markdown(.md)、JSON(.json)、YAML(.yml)、CSV(.csv)などのファイルを簡単に管理・表示できます。それらのファイルの内容をそのままページコンテンツにでき、検索やフィルタ、ソート機能などコンテンツを管理する方法を多く提供しています。マークダウンの中でカスタム Vue コンポーネントを呼び出すことができ、文章だけではなくリッチなコンテンツを構築できます。そのため、ブログや企業公式サイトで使われることが多いです。

v2 からの v3 への変更点

マイグレーションガイドを読んで、v2 と比較しての一番の大きな変更点はコンテンツのデータの管理と取得方法です。

Announcing Nuxt Content version 3

Announcing Nuxt Content version 3

https://content.nuxt.com

Announcing Nuxt Content version 3

SQL Storage - Nuxt Content

SQL Storage - Nuxt Content

https://content.nuxt.com

SQL Storage - Nuxt Content

今までの nuxt content v2 は、コンテンツ管理をストレージシステムをファイルベースとしており、データについてはすべてプロジェクトの中で完結するのが魅力でした。それぞれのコンテンツに対してキャッシュファイルを作成するのですが、必然とすべてのキャッシュファイルをロードすることになり、プロダクション環境での I/O が大幅になっていました。

そのため、多くのコンテンツを持つプロジェクトだと、リクエストの度にゼロからサーバーを起動するサーバーレス環境とは相性が悪かったみたいです。状態を持たないサーバーレス環境の中でサーバー内のメモリやファイルベースのデータベースは使えないので、v3 からは永続化されたデータベースからデータを取得する変更にしました。

今回 v3 では、AST へ変換したコンテンツを事前に定義したスキーマと沿っているかを確かめるのを兼ねて、テーブルに入れ、dump ファイルに落とし込みます。そして、コードスタート時にその dump ファイルをデータベースへデータをリストアするとのことです。コンテンツはもちろん最新のものだったり、重複を弾くなどの工夫はしているようです。

このデータベースにデータをリストアするアプローチがクライアントのナビゲーションでも応用されていることについて魅力的に感じました。最初クエリが発行されたときに、dump ファイルを取得し、ブラウザ内のデータベースへデータをリストアすることで、サーバーへの通信がなくなります。これにより、データ取得がクライアント内で完結します。つまり、レスポンスも早くなり、オフライン環境でもコンテンツを見ることが可能になります。

マイグレーション内容

マイグレーションガイド自体は nuxt content の公式サイトにあるので、まずはそれに目を通すことを推奨します。v2 のころから提供されているコンポーネントのリネームや削除、上記に記載したようにコンテンツが SQL データベースで保存されることから、取得 API の名前や方法がまるっと変更されています。

The git-based CMS for Nuxt projects.

The git-based CMS for Nuxt projects.

https://content.nuxt.com

The git-based CMS for Nuxt projects.

以下には、このブログにおいて印象だった変更をまとめます。

Collection の定義

v3 からコンテンツを関連したものを Collection でグルーピングするという考えができました。これからコンテンツをデータベースで管理するというのも相まって、それぞれのグループのスキーマを定義します。その定義をするために、v3 から content.config.ts というファイルをルートに置く必要があります。

以下はこのブログでの内容です。

content.config.ts
import path from 'path'

import { defineCollection, defineContentConfig, z } from '@nuxt/content'

export default defineContentConfig({
  collections: {
    posts: defineCollection({
      type: 'page',
      source: {
        cwd: path.resolve('src/content'),
        include: '**',
      },
      schema: z.object({
        title: z.string(),
        description: z.string(),
        publishedAt: z.string(),
        updatedAt: z.string(),
        categories: z.array(z.string()),
        draft: z.boolean(),
        secret: z.boolean(),
        priority: z.number(),
      }),
    }),
  },
})

zod を使ってコンテンツの型を定義できるようになったのは、非常に便利だと感じました。今までだと自分で interface を定義して、queryContent に型情報を渡して初めて、型が付与されたのですが最初から型が付与される形となりました。

Nuxt content ではデフォルトでコンテンツの保管場所を content という名前で Nuxt プロジェクトのルートディレクトリの中を指定しています。しかし、その保管場所を変更でき、source プロパティにその保管場所を伝えることで可能になります。このブログでは、コンテンツの場所は src/content ディレクトリになっているので、source プロパティの cwd を書き換えています。ここで注意すべきなのは、cwd プロパティには 絶対パス を渡すことです。最初 Nuxt プロジェクトの相対パスを渡していましたが、dev server 起動中の hot reload が効かなくて、数時間溶かしました。コンテンツの中身に変更があったときは、以下のようなログが出力されます。

changed.txt
[@nuxt/content 18:02:45] ℹ File tech/nuxt-content-v3-migration.md changed on posts collection

Collection の定義が書けたら、nuxi prepare コマンドを実行して、型を生成します。これからコンテンツについてのプロパティ参照するときは、PostsCollectionItem からプロパティを選択します。

.nuxt/content/types.d.ts
import type { PageCollectionItemBase, DataCollectionItemBase } from '@nuxt/content'

declare module '@nuxt/content' {
  interface PostsCollectionItem extends PageCollectionItemBase {
    title: string;
    description: string;
    publishedAt: string;
    updatedAt: string;
    categories: string[];
    draft: boolean;
    secret: boolean;
    priority: number;
  }

  interface PageCollections {
    posts: PostsCollectionItem
  }

  interface Collections {
    posts: PostsCollectionItem
  }
}

markdown に関する設定を build プロパティでラップするように

マイグレーションガイドには特に記載はなかったのですが、nuxt.config.ts に書くマークダウンに関する設定たちを build プロパティでラップします。

nuxt.config.ts
diff --git a/nuxt.config.ts b/nuxt.config.ts
index c1858fd..c8995e4 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -42,45 +42,45 @@ export default defineNuxtConfig({
   },

   content: {
-    markdown: {
-      rehypePlugins: [
-        [
-          'rehype-external-links', {
+    build: {
+      markdown: {
+        rehypePlugins: {
+          'rehype-external-links': {
             target: '_blank',
             rel: 'nofollow noopener noreferrer',
           },
-        ],
-      ],
-    },
+        },

-    highlight: {
-      theme: {
-        default: 'material-theme',
-      },
+        highlight: {
+          theme: {
+            default: 'material-theme',
+          },

-      langs: [
-        'json',
-        'js',
-        'ts',
-        'html',
-        'css',
-        'vue',
-        'shell',
-        'mdc',
-        'md',
-        'yaml',
-        'dart',
-        'xml',
-        'csv',
-        'ruby',
-        'go',
-        'diff',
-        'docker',
-        'dotenv',
-        'python',
-        'scss',
-        'sql',
-      ],
+          langs: [
+            'json',
+            'js',
+            'ts',
+            'html',
+            'css',
+            'vue',
+            'shell',
+            'mdc',
+            'md',
+            'yaml',
+            'dart',
+            'xml',
+            'csv',
+            'ruby',
+            'go',
+            'diff',
+            'docker',
+            'dotenv',
+            'python',
+            'scss',
+            'sql',
+          ],
+        },
+      },
     },
   },

コンテンツの内部プロパティ名から _ の接頭語をなくす

取得したコンテンツの内部プロパティは接頭語を外したものにリネームされました。このブログでは記事へのパスを参照しているので、プロパティを _path から path に変更しました。

PostCard.diff
diff --git a/src/components/post/PostCard.vue b/src/components/post/PostCard.vue
index f119d51..085f0c1 100644
--- a/src/components/post/PostCard.vue
+++ b/src/components/post/PostCard.vue
@@ -9,7 +9,7 @@ const props = defineProps<{
 }>()

 const thumbnailPath = computed(() => {
-  return `/content/${props.content._path?.split('/')[2]}/thumbnail.png`
+  return `/content/${props.content.path?.split('/')[2]}/thumbnail.png`
 })
 </script>

@@ -21,7 +21,7 @@ const thumbnailPath = computed(() => {
     <ULink
       :tabindex="-1"
       :aria-hidden="true"
-      :to="`/posts${content._path}`"
+      :to="`/posts${content.path}`"
       class="absolute inset-0"
     />

記事の取得方法の変更

表示するコンテンツを変更するには定義した Collection から動的に取得します。queryContent から queryCollection にリネームされ、collection を指定することで使えます。MongoDB のような NoSQL ベースのクエリから、SQL ベースのクエリに変更されました。

queryCollection.diff
 const fetchAllCountByCategories = async (): Promise<number> => {
-  return queryContent<CustomParsedContent>()
-    .where({
-      categories: { $contains: cateogryParams.value },
-      draft: { $not: true },
-      secret: { $not: true },
-    })
+  return queryCollection('posts')
+    .where('categories', 'LIKE', `%${cateogryParams.value}%`)
+    .andWhere((query) => query.where('draft', 'IS NULL').where('secret', 'IS NULL'))
     .count()
 }

nitro のサーバー側でもリネームの変更を受け、同じく queryCollection を使います。このブログでは、Google Search Console に sitemap を登録しており、nitro を利用して sitemap.xml を返却するエンドポイントを用意しています。

sitemap.diff
diff --git a/src/server/routes/sitemap.xml.ts b/src/server/routes/sitemap.xml.ts
index ab867c1..050a821 100644
--- a/src/server/routes/sitemap.xml.ts
+++ b/src/server/routes/sitemap.xml.ts
@@ -1,13 +1,10 @@
 import { join } from 'path'

-import { defineEventHandler } from '#imports'
+import { defineEventHandler, queryCollection } from '#imports'
 import { SitemapStream, streamToPromise } from 'sitemap'

-import { serverQueryContent } from '#content/server'
-import type { CustomParsedContent } from '~/types/content'
-
 export default defineEventHandler(async (event) => {
-  const docs = await serverQueryContent<CustomParsedContent>(event).find()
+  const docs = await queryCollection(event, 'posts').all()
   const sitemap = new SitemapStream({
     hostname: 'https://offich.me',
     lastmodDateOnly: true,

コンテンツの前後のものを取得する findSurroundqueryCollectionItemSurroundings という API と使って取得します。

findSurround.diff
diff --git a/src/pages/posts/[...slug].vue b/src/pages/posts/[...slug].vue
index cfde233..02e684f 100644
--- a/src/pages/posts/[...slug].vue
+++ b/src/pages/posts/[...slug].vue
@@ -38,9 +36,9 @@ useSeoMeta({
 const { data: surround } = await useAsyncData(
   `docs-${routePath.value}-surround`,
   () => {
-    return queryContent<CustomParsedContent>()
-      .where({ _extension: 'md', navigation: { $ne: false }, draft: { $not: true }, secret: { $not: true } })
-      .findSurround(routePath.value)
+    return queryCollectionItemSurroundings('posts', routePath.value, { fields: ['title', 'description'] })
+      .andWhere((query) => query.where('draft', 'IS NULL').where('secret', 'IS NULL'))
   },
   { default: () => [] },
 )

ProsePreProseCodeProseCodeInline コンポーネントの統合

Nuxt content は MDC (Markdown Components) syntax を採用しており、コンテンツの内容に応じてコンポーネントが割り当てられ、HTML タグが描画されます。マークダウンの記法で、`(1 点バッグスラッシュ) と ```(3 点バックスラッシュ)を使うと、MDC がそれぞれインラインかブロックコードを描画します。

v3 は改めてそのコードを描画するコンポーネントたちの整理がされました。Nuxt content は @nuxtjs/mdc が提供しているコード描画のコンポーネント(= Prose Components) を使用しています。components/content ディレクトリに同じ名前のコンポーネントを作成することで、どう描画するかを自分で決められます。このとき ProseCodeInlineProseCodeProsePre をコンポーネントを自前で用意している人はマイグレート作業が発生します。

v2 で ProseCodeInline がただの ProseCode のラッパーであることから、v3 ではインラインで書いてあるコードは ProseCode と処理されます。またブロックコードは ProseCode の代わりに、ProsePre として処理されます。そのため、ProseCodeInlineProseCode にリネームし、ProseCode にあるロジックを ProsePre に移動させれば、v3 へマイグレートできます。

Migration - Nuxt Content

Migration - Nuxt Content

https://content.nuxt.com

Migration - Nuxt Content

まとめ

いかがだったでしょうか。Nuxt content についてのマイグレーションガイドを書きましたが、変更点が多いものとなりました。上記に書いたのはほんの一部で、場合によっては記載しなかったマイグレート作業が発生するので、注意が必要です。

特にデータベースの準備は破壊的変更だと感じました。幸いこのブログは SSG でビルドしている(Static Hosting)ので、データベースを用意する必要はありませんでしたが、大きな作業かと感じるのでリリースする前に動作検証をおすすめします。

v3 がリリースされたのは 1 月ですが、3 月末の現在でも新しいバージョンが次々とリリースされています。個人的にはブログを完成させてくれた技術というのもあり、これから使い続けるので、何かまた変更があれば、このブログでまとめたいと考えています。