Nuxt ContentからOx Contentへ、1000ページ超のブログ基盤刷新への挑戦 - Slidev

-- Views

October 24, 26

スライド概要

Vue Fes Japan 2026 での登壇資料です。
https://vuefes.jp/2026/speaker/ykoizumi0903

profile-image

Vue, Nuxt, TypeScript, Splatoon が好きなフロントエンドエンジニアです。 ANDPAD で働いています。

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

(ダウンロード不可)

関連スライド

各ページのテキスト
1.

Nuxt ContentからOx Contentへ 1000ページ超のブログ基盤刷新への挑戦 Yutaro Koizumi 2026-10-24 @ Vue Fes Japan 2026

2.

Yutaro Koizumi ( @ykoizumi0903 ) 2019年 アンドパッドに入社 ERP(引合粗利管理)フロントチーム テックリード 過去の登壇 Nuxt × Vue Router の 力を最大限に引き出す機能を紹介 (Vue Fes Japan 2024) Vue・React マルチプロダクト開発を支える Vite (#vuefes_fec_tokyo) Vue にまつわる3つのよくある誤解と、私がVueを選ぶ4つの理由

3.

株式会社アンドパッド 建設業に特化したクラウドサービス「ANDPAD」を開発しています スポンサーブース出展しているのでぜひ遊びに来てください!

4.

※今日の発表内容は、業務とは関係ない趣味の話です

5.

今回の題材:個人ブログのリニューアルの話 大学時代に開設、記事数は1000本以上 Nuxt Content で開発していたが、表示速度や運用面などの課題が出てきていた 当初の計画: Nuxt・SSR・Nuxt Content を一気に置き換える大規模なリニューアル (Vue Fes での発表も意識して、派手な改善を見せたかった) アプリ基盤 Nuxt → Vite + Vue コンテンツ基盤 Nuxt Content → Ox Content レンダリング方式 SSR → SPA しかし、最終的に、NuxtとSSRの置き換えは断念し、コンテンツ基盤だけを Ox Contentへ移行しました なぜこの判断に至ったのか、それがどのような効果が出たのかを紹介します

6.

今日のアジェンダ 1. ブログサイトの改善前の構成 2. Nuxt Content + NuxtHub で見えてきた課題 3. 技術基盤の見直しと、どう意思決定したか 4. Markdown 変換の高速化 5. Ox Content での全文検索の実装 6. 移行結果と技術選定の学び

7.

1. ブログサイトの改善前の構成

8.

個人ブログの概要 元々は単なる趣味のブログだった 2019年にアンドパッドに入社してからは、興味のある技術を試す場として活用するようになった 〜2019 WordPress 2020 Nuxt.js 2021 Next.js 2023 Nuxt 3 2025〜 Nuxt 4 定期的にリニューアルを行うメリット チュートリアルを触るだけでは得られない知見がある 実際の使用感に基づいて技術記事を書くことができる 祝・正式リリース!5つのテーマで理解する Nuxt3 の魅力 Nuxt3 サイトを Google Cloud Run にデプロイする方法(Dockerfile サンプル付き) 会社での技術選定や新しい挑戦に繋がる 個人開発で Next.js を試していたおかげで、会社で急に React プロジェクトを任されても対応できた

9.

最新の技術構成 (Ox Content 導入前) ① Markdown Front Matter 本文 → ② Nuxt Content 読み取り・クエリ・検索 コレクション化 主な技術 → ③ Cloudflare Workers D1 Database R2 Storage → ④ Nuxt ページ Server API フロントエンド:Nuxt 4 ホスティング:NuxtHub (Cloudflare Workers) 記事管理・検索:Nuxt Content(D1連携) 記事本文・画像:R2 Storage Nuxt 向けの Git ベース CMS。Markdown などの記事を管理 し、クエリで取得・表示できる DB・ファイル保存・キャッシュなど、フルスタックアプリの 構築・デプロイをサポート。

10.
[beta]
Markdown は公開前に変換
① Markdown
→ ② Nuxt Content →

③ Cloudflare

可能な限り前処理で集計や変換を済ませ、CI やクライアントのコストを削減する

→

④ Nuxt

記事の原本は Markdown ファイルとして Git 管理
公開前にローカルで HTML へ変換し、本文は R2、メタデータは記事データとして保存

変換前の Markdown + Frontmatter

---

変換後の記事データ

{

id: 100
title: タイトル
date: "2026-10-01T22:57:40+09:00"

"id": 100,
"slug": "title",
"title": "タイトル",

--記事の本文がここに入ります。

"contentPath": "posts/2026/10/title.txt",
"contentPlainText": "記事の本文がここに入ります。",
"date": "2026-10-01T22:57:40+09:00",
"postType": "post",
"postStatus": "publish"
}

11.
[beta]
Nuxt Content で記事をコレクション管理
① Markdown
→ ② Nuxt Content →

③ Cloudflare

SQL を意識せずに、コレクションとして記事を管理する

→

④ Nuxt

変換後の JSON をコレクションとして登録し、本文とメタデータを読み取る

nuxt.config.ts:モジュール登録
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["@nuxt/content"],
});

content.config.ts:記事コレクション定義

// content.config.ts
import { defineCollection, defineContentConfig } from '@nuxt/content'
export default defineContentConfig({
collections: {
posts: defineCollection({
type: "page",
source: "posts/*/*/*.json",
})
}
})

12.

queryCollection で一覧・絞り込み・全文検索 ① Markdown → ② Nuxt Content → ③ Cloudflare SQL を意識せずに、コレクションとして記事を管理する → ④ Nuxt Nuxt Contentで記事をコレクションとして管理し、 queryCollection で一覧・絞り込み・ページネーショ ンを実行 const posts = await queryCollection("posts") .where("category", "=", "Vue") .order("date", "DESC") .limit(10) .all() 全文検索も LIKE クエリで実現(※タグや記号を除去したプレーンテキストを別に保持しておく)

13.
[beta]
NuxtHubでフルスタック Nuxt アプリを構築
① Markdown
→ ② Nuxt Content → ③ Cloudflare
NuxtHub が Cloudflare Workers との連携をスムーズにする
export default defineNuxtConfig({
modules: ["@nuxthub/core", "@nuxt/content"],
hub: {
blob: {
driver: "cloudflare-r2",
binding: "BLOB",
bucketName: "**",
},
db: {
driver: "d1",
connection: {
databaseId: "**",
},
},
},
content: {
database: { type: "d1", bindingName: "CF_BINDING_NAME" },

→

④ Nuxt

nuxt.config.ts で Cloudflare D1 を設定
content.database :Nuxt Content のデータ
ベースに D1 を指定
bindingName :Cloudflare Workers 側の D1
接続名を指定
hub.db.driver :NuxtHub のデータベースに
も D1 を指定

14.
[beta]
Cloudflare の D1・R2 の使われ方
① Markdown
→ ② Nuxt Content →
NuxtHub が Cloudflare Workers との連携をスムーズにする

D1:SQLite 互換のサーバーレスデータベース

記事のメタデータや検索用データを保存し、Nuxt
Content のクエリで取得
const post = await queryCollection("posts")
.where("postId", "=", Number(id))
.first();

③ Cloudflare

→

④ Nuxt

R2:本文・画像用オブジェクトストレージ

記事本文や画像ファイルを保存し、NuxtHub の Blob
API から取得・配信
import { blob } from "hub:blob";
const file = await blob.get(contentPath);
const content = await file?.text();
return blob.serve(event, imagePath);

15.
[beta]
Nuxt Server APIがメタデータと本文を組み合わせて返す
① Markdown
→ ② Nuxt Content → ③ Cloudflare →

④ Nuxt

Nuxt の Server API から queryCollection を呼び出し、必要なデータだけを取得する

ブラウザは Nuxt Server API のみを呼び、API が D1 のメタデータと R2 の本文を組み合わせて返す

記事詳細 API

R2 から本文を取得

// server/api/post.ts
import { blob } from "hub:blob";

const getTextFromBlobFile = async (pathname: string) => {
const file = await blob.get(pathname);
return file?.text() || "";

export default defineEventHandler(async (event) => {
const { id } = getQuery(event);
const post = await queryCollection("posts")

};

.where("postId", "=", Number(id))
.first();
if (!post) return { data: null };
const content = await getTextFromBlobFile(post.contentPath);
return { data: { ...post, content } };
});

16.

ここまでの構成を整理 Markdown remark + rehype Nuxt Content D1 Server API Nuxt Client Cloudflare Workers 1. 公開前に Markdown を変換して記事データを生成、本文と画像は R2 に保存 2. メタデータは Nuxt Content を通して D1 に保存 3. Nuxt Server API が NuxtHub を通し、 D1 と R2 から必要なデータを取得 4. クライアントは Nuxt Content やストレージに直接アクセスせず、API を通してデータを取得 5. Cloudflare Workers でホスティング Cloudflare, Nuxt, NuxtHub, Nuxt Content を全面的に活用し、少ないコードで効率的に記事管理・配信の仕 組みを構築

17.

2. Nuxt Content + NuxtHub で見えてきた課題

18.

大前提:NuxtHub + Nuxt Content は非常に便利 決して「オススメしない」という発表ではありません! Nuxt の開発体験を活かしながら、記事の管理・検索・配信まで少ないコードで構築できる D1 Database との連携も、NuxtHub が多くの部分を面倒見てくれる コスト的にも非常に安価 大きな不満はなく、個人ブログや小規模なメディアサイトであれば、十分に現実的な選択肢

19.

ただし、運用を続ける中で、気になる点も出てきました

20.

①D1 Database を経由する必要がやや過剰 毎回 D1 Database とのデータ同期が入ることで、ビルド・デプロイに時間がかかる Nuxt Content を通すことで DB を自分で管理しなくて良いが、全記事分のマイグレーションが毎回走る ため、ビルド時間が長くなる 実際の表示速度面でも、D1 Database へのアクセスがボトルネックになっている Markdown を元にした静的ファイルを D1 経由で扱うのは、コスト面でも無駄が大きい ⇒ 静的コンテンツに対して、データベースを経由する構成は重い

21.

②自動生成されたDB処理がブラックボックス化している Nuxt Content が Migration ファイルを自動生成してくれるので、SQL を意識せず使えるが、 データベースの中身や処理の流れが見えづらい 大きな記事データの登録で SQLITE_TOOBIG エラーが発生し、自分で書いた SQL ではないため原因特定 に時間がかかった 最終的に本文を txt ファイルへ分離して R2 Storage を経由することで回避したが、根本原因は追いづら かった Failed to apply database query content-database-002: Failed to query database: statement too long: SQLITE_TOOBIG 他にも、Nuxt のアップデート後、トップに古い記事が表示されてしまう問題が起きたが、原因を特定でき ずにバージョンを戻して対処していた ⇒ ライブラリに任せる便利さとのトレードオフ

22.

③Nuxtへの依存度が高くなりすぎている Nuxt 関連ライブラリは密結合になりやすく、特定のライブラリだけをアップデートしたり入れ替えたりし づらい 個人開発というよりは業務の方で、 Nuxt への依存度が高すぎることの弊害を感じていた NuxtHub Admin のサービス終了 により、Nuxt Modules に依存しすぎるリスクを意識するようになった Vapor Mode や Oxc, Vite Plus といった VoidZero 関連の最新技術への追従が遅れやすい ⇒ Nuxt を使わずにサイトを構築できるならその方が良いという気持ちが出てきた

23.

④Ox Content に興味を持った Nuxt Content からの移行先を探している中で偶然流れてきた 筋と憎悪,もののけ王 @ubugeeei · Follow はドキュメントジェネレータで あると同時に,マークダウンフレームワーク でもあるし,ライブラリでもあるし,ユーテ ィリティでもある.そして宇宙最速 Ox Content 7:07 PM · Jun 8, 2026 2 Reply Copy link Read more on X 筋と憎悪,もののけ王 @ubugeeei · Follow の面白いところは,Framework だけど Framework Integration が実 装されてて,Dynamic Og Image もコンポー ネントでかけつつ,Chromium ベースだから CSS が自由なところ Ox Content Agnostic 11:20 PM · Mar 7, 2026 Reply Copy link Read 1 reply

24.

④Ox Content に興味を持った OXCONTENT ( ) ubugeeei 氏が開発しているRust 製の Markdown ツールキット ドキュメントサイト生成に加え、Markdown の解析・HTML 描画・変換機能を提供 特定のフレームワークに依存せず必要な機能を選んで利用できる ⇒ 高性能な Rust 製ツールであれば、単純な置き換えでも速度改善が期待でき、 Nuxt への依存を下げたいという意図ともマッチしている

25.

3. 技術基盤の見直しと、どう意思決定したか

26.

最初に目指していたリニューアル アプリ基盤 Nuxt → Vite + Vue コンテンツ基盤 Nuxt Content → Ox Content 目的 ビルド・デプロイ時間を短縮し、コスト削減に繋げたい 表示速度をさらに向上させたい Nuxtへの依存度を下げ、Nuxt を使わない開発を経験したい レンダリング方式 SSR → SPA

27.

検討①:Nuxtを外し、Vite + Vueで組み直す アプリ基盤 Nuxt → Vite + Vue コンテンツ基盤 Nuxt Content → Ox Content 検証結果:置き換えるコード量があまりにも膨大 Nuxt では特に意識せず使える細かい機能に頼っていることに気づかされた File-based Routing、useFetch / useAsyncData、middleware、layout 既存アプリを移行するのは現実的ではない NuxtHub Module も、Cloudflare を使い続ける前提ならやはり便利 ⇒ 周辺機能の再実装コストが目的に見合わず、Nuxt をそのまま活用 レンダリング方式 SSR → SPA

28.

検討②:Nuxt ContentをOx Contentへ置き換える アプリ基盤 Nuxt → Vite + Vue コンテンツ基盤 Nuxt Content → Ox Content レンダリング方式 SSR → SPA 検討理由: Ox Content が特定のFW に依存しないなら、Nuxt とも組み合わせられるはず Nuxt は維持しつつ、D1 への依存を解消するためにコンテンツ処理だけを置き換える ページや Server API の役割は変えず、記事取得の query を差し替えられるか検証する Before:Nuxt Content After:Ox Content

29.
[beta]
Nuxt へ Ox Content plugin を登録
import { resolve } from "node:path";
import { defineCollection, oxContent } from "@ox-content/vite-plugin";
import { defineNuxtConfig } from "nuxt/config";
import type { Plugin as RollupPlugin } from "rollup";
const oxContentPlugin = oxContent({
srcDir: resolve("content"),
docs: false,
collections: {
posts: defineCollection({
source: "posts/**/*.mdx",
}),
},
}) as RollupPlugin[];
export default defineNuxtConfig({
nitro: {
rollupConfig: { plugins: oxContentPlugin },
},
});

Ox Content を Nitro の Rollup pluginと
して登録することで動作
でコレクションを定義、
virtual:ox-content/collections を利
用可能にする
oxContent()

※ virtual:ox-content/collections を参照する
Server API ( server/ 配下)をバンドルするのは
Nitro 側のパイプラインであるため、Vite プロパテ
ィではなく Nitro への登録が必要

30.

検証結果:十分な改善効果が得られた アプリ基盤 Nuxt → Vite + Vue コンテンツ基盤 Nuxt Content → Ox Content レンダリング方式 SSR → SPA 既存のページやAPIを大きく作り直さずに移行 D1 Database を通さず記事を取得し、表示速度を改善 計測対象 Before(Nuxt Content) After(Ox Content) 最新記事一覧 465 ms 13 ms カテゴリー別一覧 21 ms 10 ms ※ Nuxt Content v3 は SQL ベースのため、Workers 上では D1 利用が前提 D1 依存を解消するためにも仮想モジュール形式の Ox Content に切り替える必要があった 短縮率 97.2% 52.4%

31.

Lighthouse スコアの比較 Before After

32.

検討③:SSRをやめて、記事を Client で処理する アプリ基盤 Nuxt → Vite + Vue コンテンツ基盤 Nuxt Content → Ox Content レンダリング方式 SSR → SPA 検証結果:Ox Content は仮想モジュールのため、記事全体を含めると CF Workers のサイズ制限を超過 ⇒ 回避のために有料プランに上げる・外部ストレージ等へ逃がすのは本末転倒なので、SSR を維持 ※ SSG は、1000以上のページを事前生成すると時間がかかりすぎるため最初から検討しなかった

33.

技術刷新の方針を見直す アプリ基盤 Nuxt → Vite + Vue コンテンツ基盤 Nuxt Content → Ox Content 記事取得方法を置き換える小さな変更で、取得時間を大きく短縮できた ⇒ 脱 Nuxt や脱 SSR が手段ではなく目的になってしまっていたのではないか? 費用対効果の低い Nuxt・SSR の脱却はせず、Ox Content への置き換えに注力する レンダリング方式 SSR → SPA

34.

4. Markdown 変換の高速化

35.

課題:公開前の Markdown 変換処理をさらに高速化したい これまでの変換処理: unified, remark, rehype を組み合わせたパイプライン const html = await unified() .use(remarkParse) .use(remarkRehype) .use(rehypeStringify) .process(markdown); 安定性は高いが、変換処理の速度には改善の余地があった Markdown → @ox-content/napi Node.js から使える native API → HTML Ox Content が提供している、 Markdown のパース・HTML 変換のみの API に置き換えてみる

36.
[beta]
Markdown (frontmatter) のパースを Ox Content に移行
BEFORE

AFTER

import { parseFrontMatter } from "remark-mdc";

import { prepareSource } from "@ox-content/napi";
fs.readFile(path, "utf-8", (err, data) => {
const { frontmatter, content } = prepareSource(data, {
frontmatter: true,
});
});

fs.readFile(path, "utf-8", (err, data) => {
const parsed = parseFrontMatter(data);
const { data: frontmatter, content } = parsed;
});

remark-mdc

2.977 ms

→

94.3% 短縮

Ox Content (prepareSource)

0.171 ms

37.
[beta]
記事本文の HTML 変換を Ox Content に移行
BEFORE

AFTER

import rehypeStringify from "rehype-stringify";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";

import { parseAndRender } from "@ox-content/napi";
const { html, errors } = parseAndRender(markdown);

const html = await unified().use(remarkParse)
.use(remarkRehype)
.use(rehypeStringify)
.process(markdown);

処理が高速化されただけでなく、コードや依存関係もシンプルに
remark + rehype

28.32 ms

→

98.7% 短縮

Ox Content (parseAndRender)

0.37 ms

38.

5. Ox Content での全文検索の実装

39.
[beta]
現状:全文検索は Nuxt Content に任せていた
検索対象の本文を Nuxt Content で検索
カテゴリーやタグで検索するのとほぼ同じ実装
LIKE 演算子をサポートしているため、全文検索を意識して実装する必要はなかった
// Before:Nuxt Content queryで本文をLIKE検索
import { queryCollection } from "@nuxt/content/server";
const searchResults = await queryCollection("posts")
.where("contentPlainText", "LIKE", `%${keyword}%`)
.order("date", "DESC")
.all();

参考:https://content.nuxt.com/docs/utils/query-collection

40.

Ox Contentで全文検索を実装できるか検証 Nuxt Contentと同じように、 Ox Content も LIKE 演算子をサポートしている const matches = await queryCollection("posts") .where("contentPlainText", "LIKE", `%${keyword}%`) .all(); ⇒ 本文を JSON に入れると、 CF Workers のサイズ制限を超過(SPA化と同じ問題) 外部ライブラリ (Pagefind、Algolia など) を導入するか 自前で検索機能を実装するか

41.
[beta]
結論:検索だけを自前実装した
D1 に記事のタイトル・本文を登録し、単語ごとの索引から検索に一致する記事IDを取得
Markdown の事前変換時に、公開記事のID・タイトル・本文をD1へ登録
検索結果として返却された記事IDから、Ox Contentで記事情報を取得
-- 記事データと全文検索用の索引を更新
INSERT INTO posts_search (id, title, body)
VALUES (:id, :title, :body)
ON CONFLICT(id) DO UPDATE SET title = excluded.title,
body = excluded.body;
DELETE FROM posts_search_index WHERE id = :id;
INSERT INTO posts_search_index (id, title, body)
VALUES (:id, :title, :body);

// server/utils/useContentQuery.ts
const rows = await db.all(sql`
SELECT id FROM posts_search_index
WHERE posts_search_index MATCH ${keyword}
`);
const ids = rows.map(({ id }) => id);
// 検索結果の記事IDでOx Contentを絞り込む
query = query.where("postId", "IN", ids);

実装には AI を活用しながら、最小限の機能に抑えることでメンテナンス性を担保

42.

効果:表示時間を短縮し、D1読み取りを削減 検索は新サイト内では相対的に遅いが、利用頻度を踏まえて許容 計測対象 検索 旧サイト 1.11 s 新サイト 547 ms 短縮率 51% D1参照を全文検索時だけに限定したことで、D1 Database の読み取り回数が激減し、コスト圧縮に成功

43.

まとめ:移行結果と技術選定の学び

44.

大規模な作り直しではなく「部分刷新」という選択 基盤は大きく変えず、ボトルネックだけを Ox Content に置き換えた 変更前 Markdown remark + rehype Nuxt Content D1 Nuxt Client Server API Cloudflare Workers Server API Nuxt Client 変更後 Markdown @ox-content/napi Ox Content Cloudflare Workers 「脱〇〇」を目的にせず、費用対効果を見極める 大規模な作り直しは魅力的だが、再実装コストや Workers の制約に対して割に合わない 課題の根本(D1 依存・変換速度)だけをピンポイントで解消するのが最も効果的だった

45.

フレームワークやライブラリは「必要な機能だけ」を使う 「Nuxt を辞めれば複雑性が下がる」という単純な話ではなかった Nuxt は無理に剥がさず、安定したルーティングやサーバー基盤として活用する 一方で Nuxt Modules を重ねて密結合にすると、ブラックボックス化や移行の障壁になる Nuxt Modules を減らすだけでも「Nuxt の依存度を下げる」ことに繋がる ライブラリの機能も、必要な範囲だけを疎結合に利用する Ox Content も全機能(ページ生成等)ではなく、コンテンツ管理や Markdown 変換 API のみを利用 全文検索も D1 を最小限(ID・タイトル等)に絞ることで、シンプルさと保守性を両立 汎用的な Vite / Rollup プラグインを活用し、ポータビリティを保つ

46.

AI時代だからこそ、要件に合った「筋の良い」技術を選ぶ 技術方針さえ決まれば、実装の置き換えは AI によって劇的に低コスト化 今回の移行作業でも、具体的なコードの実装や query の書き換えはほとんど AI を活用 だからこそ、「筋の良い技術」を判断することが人間の重要な役割 Nuxt も Ox Content も、すべての技術に一長一短がある 技術選定に銀の弾丸はない 制約や要件、将来性や拡張性を考慮して、技術選定を行うことが重要 (私は) Vue, Vite, Nuxt, Oxc の開発チームの"筋の良さ"を信頼しているので、積極的に使っていきたい

47.

ご清聴ありがとうございました!