Markdownはそのまま。Next.jsブログをvinext+Workersで動的化した
検索できるブログにしたかった
このブログは、Next.jsで作ったページを静的ファイルとして出力し、Cloudflare Pagesで公開していました。記事はMarkdownで管理しています。
記事を書く仕組みはそのままでよかったのですが、過去の記事をキーワードで探したり、カテゴリーごとに表示したりする部分を、もう少し扱いやすくしたくなりました。
そこで今回は、記事のMarkdown管理を維持しつつ、vinext+Cloudflare Workersでページを動的に描画する構成へ移行しました。
コードを変更するだけでは終わらず、Cloudflare側の設定変更や、GitHubと連携できているのに「Hello World」が表示される場面もありました。その過程を記録しておきます。
「Cloudflareでは静的サイトだけ」という認識を見直した
もともとは、Cloudflareで公開するには静的出力が必要だと思っていました。ただ、調べ直すと、Next.jsの動的処理への対応は以前からあり、実行方式や対応範囲が変わってきたことが分かりました。
2022年にはPagesでNext.jsのEdge Runtimeに対応しています。その後、2024年にOpenNext経由でWorkersへデプロイする仕組みが登場し、2025年にはCloudflare用アダプターの1.0ベータが公開されました。2022年の公式発表、2025年のOpenNext対応に関する公式発表
今回使ったvinextは、Next.jsのAPIをVite上で再実装する仕組みです。既存のApp Routerの画面を活かしながら、ビルドと実行の構成を変更しました。Next.jsのビルド結果を変換するOpenNextとは方式が異なります。vinext公式リポジトリ
今回検証したvinextは 1.0.0-beta.10 です。ベータ版なので、手元のアプリで互換性を確認してから進めました。
なお、検索機能そのものは静的サイトでもブラウザ側のJavaScriptで実装できます。今回は、検索条件をサーバー側で処理し、結果をHTMLとして返す構成を選んでいます。
ページの生成タイミングを変える
移行前のNext.js設定には、次の指定がありました。
output: 'export'
これは静的サイトとして書き出す設定です。また、記事ページとカテゴリーページでは generateStaticParams を使って、ビルド時に生成するURLを列挙していました。
今回の変更では、この静的出力設定とページの事前生成処理を取り除き、ルートレイアウトに次の指定を追加しました。
export const dynamic = 'force-dynamic'
アクセス時に検索条件やURLのカテゴリーを読み取り、該当する記事を表示します。CSS・JavaScript・画像などの静的アセットは引き続き配信しますが、ブログページのHTMLを out に書き出す構成ではなくなりました。
Viteの設定は次の形です。
import { defineConfig } from 'vite'
import vinext from 'vinext'
import { cloudflare } from '@cloudflare/vite-plugin'
export default defineConfig({
plugins: [
vinext(),
cloudflare({
viteEnvironment: {
name: 'rsc',
childEnvironments: ['ssr'],
},
}),
],
})
Markdownは維持し、読み込み方を変更した
ここが、今回の移行で大事だった部分です。
もとの実装では、記事を取得するときに fs を使って content/articles のMarkdownを読んでいました。これをそのままリクエスト時の処理にすると、デプロイ先でもローカルと同じ場所に記事ファイルが存在することを前提にしてしまいます。
そこで、起動・ビルド時に公開記事をJSONへまとめ、Workerのサーバー側コードに組み込む方式にしました。
処理の流れは次のとおりです。
content/articles/*.mdを読む。- frontmatterと本文を分離する。
draft: falseの記事だけを取り出す。- タイトル・カテゴリー・タグ・本文などをJSONへまとめる。
- Workerはそのデータを使って検索・表示する。
データベースは追加していません。今の規模なら、Markdownで書く運用を維持しながら検索を実装する方法として扱いやすいと考えました。
ただし、ページの動的化と、記事更新の即時反映は別です。 記事データはビルドに含めるため、記事を追加・修正したときには再ビルドと再デプロイが必要です。
将来、記事が増えて検索処理やデプロイサイズが気になるようになったら、データの保存先や検索方式を改めて検討することになります。
キーワード検索とカテゴリー表示
検索では、タイトル・概要・本文・タグ・カテゴリーを対象にしました。空白で区切ったキーワードは、すべてを含む記事に絞り込みます。
例えば、次のURLで検索結果を表示できます。
/?q=Neovim
文字列にはUnicodeのNFKC正規化と小文字化を行い、全角英数字や英字の大文字・小文字による差を吸収しています。日本語の表記揺れまで解決する高度な検索ではなく、まずは部分一致で探せる形です。
カテゴリー表示も、アクセスされたURLに応じて記事を抽出する方式に変更しました。
/categories/dev
/categories/dev?page=2
表示順は日付の降順、1ページ20件です。
最初は検索フォームにカテゴリーのプルダウンと条件クリアも置きましたが、画面を確認して削除しました。最終的にはキーワード欄と検索ボタンだけにして、カテゴリーは専用ページから辿る構成にしています。
ローカルではWorkerとして動かして確認した
今回のプロジェクトでは、次のコマンドで確認できるようにしました。
npm run check
npm run typecheck
npm run build
npm run preview
check はvinextの互換性確認、typecheck はTypeScriptの型チェックです。preview ではビルド済みWorkerをWranglerで起動します。
検索によって結果が変わること、カテゴリーで絞り込めること、記事ページが表示されること、存在しない記事やカテゴリーが404になることを確認しました。ブラウザでも検索フォームから操作しています。
また、wrangler deploy --dry-run でデプロイ用のパッケージを確認しました。ただし、dry-runは実際の公開ではありません。Cloudflare上のビルドや公開結果は、別途確認する必要があります。
作業中には、macOS上の既存 node_modules の一部が実体未取得の状態で、ファイルの読み込み待ちになる問題もありました。こちらは npm ci で依存関係を入れ直して復旧しました。vinextの互換性問題とは分けて考える必要がありました。
Pagesの設定を残したままではデプロイできなかった
Cloudflare側を確認すると、以前のPages用設定が残っていました。
Build command: npx @cloudflare/next-on-pages@1
Build output: out
今回のコードはWorkers向けです。Pagesの設定をそのまま使うのではなく、Workers側にリポジトリを接続し、今回のビルド・デプロイ方法に合わせる必要がありました。CloudflareのPagesからWorkersへの移行ガイド
今回のリポジトリで使う設定は次のとおりです。
- Worker名:
bpacket - 本番ブランチ:
master - ビルドコマンド:
npm run build - デプロイコマンド:
npx vinext-cloudflare deploy --skip-build --config dist/server/wrangler.json
Worker名は wrangler.jsonc の name と揃えます。ビルド済みWorkerの設定を使うため、デプロイ時には dist/server/wrangler.json を指定しています。
これらは今回のプロジェクトに合わせた設定です。従来の out を出力先として指定する手順とは異なります。
GitHubと連携済みでも「Hello World」が表示された
Workersへ切り替えた後、サイトに「Hello World」が表示される場面がありました。
このとき、GitHubとのリポジトリ連携はできていました。 ただし、Cloudflare側ではデプロイが失敗しており、ブログのコードが公開環境に反映されていませんでした。
ここで分かったのは、リポジトリの接続ができていることと、変更が公開されていることは、別々に確認する必要があるということです。
今回、デプロイが失敗した具体的な理由までは切り分けていません。そのため、「この設定を直せばHello Worldが解消する」と断定できる話ではありません。
同じ表示になったときには、次の順番で確認すると状況を整理しやすくなります。
- 対象のリポジトリ・ブランチ・コミットをビルドしているか。
- Cloudflare側のビルドとデプロイが成功しているか。
- 確認しているURLが、対象のWorkerに対応しているか。
コードの問題だと決めつける前に、まずデプロイ履歴とログを見る。今回のつまずきで、そこを改めて意識しました。
見た目も少し調整した
移行に合わせて、ヘッダーとフッターのロゴに「Bitcount Single」を使いました。「Currently Writing」「Articles」「About」「Navigation」にも同じフォントを適用しています。
本文の読みやすさは維持しつつ、ロゴや英語の見出しにドット風の表情を加える調整です。機能の追加と一緒に、ブログらしい見た目も少し整えました。
今回の移行を終えて
Markdownで記事を書き、Gitで管理する運用を大きく変えずに、検索とカテゴリー表示をサーバー側で処理できる構成にできました。
一方で、記事の更新には再デプロイが必要です。動的サイトにしたからといって、データの保存や更新方法まで自動的に変わるわけではありません。
今回とくに学びになったのは、アプリの実装、Cloudflareのビルド設定、実際の公開状態をそれぞれ確認することでした。ローカルで動いても、本番に同じコードが反映されているとは限りません。
まずはこの構成で記事を書きながら使い、検索の使い勝手や記事数の増加に合わせて、必要な部分を改善していこうと思います。