Next.jsの静的ブログをReact Router 8へ移行した記録(prerender)
Next.js 13の静的エクスポート(output export)で作ったブログをReact Router 8のframework modeとReact 19へ移行しました。URL・メタタグ・サイトマップをそのまま保ちつつprerenderで静的ビルドした方法と、ぶつかった問題をまとめます。
Next.jsからReact Router 8へ移行した理由
このブログは最初、Next.js 13の静的エクスポート(output: 'export')で作りました。静的エクスポートとは、サーバーを使わず、ビルド時にすべてのページをHTMLファイルとして作っておく方式です。そのHTMLをCloudflare Pagesに置いて公開していました。
next.config.jsは次のようにシンプルでした。
const {withContentlayer} = require("next-contentlayer");
const nextConfig = {
reactStrictMode: true,
output: 'export',
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"]
};
module.exports = withContentlayer(nextConfig);
問題は、サーバー機能を一つも使っていないのに、Next.jsに縛られている部分が多かったことです。Next 13.4とReact 18のままで、記事をMDXから変換するContentlayerもnext-contentlayerでNextにつながっていました。メタタグ(next-seo)やサイトマップ(next-sitemap)もすべてNext専用のパッケージでした。
そこで、静的ビルド(prerender)を標準でサポートしているReact Router 8のframework modeへ移行することにしました。framework modeはReact RouterをViteベースのフレームワークとして使う方式で、以前のRemixはこちらに統合されています。目標は一つでした。
外から見えるもの(URL、メタタグ、サイトマップ、画面)は一切変えず、内部のフレームワークだけを入れ替える。
検索エンジンにすでに登録されているURLと評価を失わないためです。
prerenderの設定:サーバーなしで全ページをHTMLに
React Routerで静的サイトを作るには、react-router.config.tsでssr: falseにしてサーバーレンダリングを止め、prerenderでビルドするパスを指定します1。
import type {Config} from "@react-router/dev/config";
export default {
appDirectory: "src/app",
buildDirectory: "build",
ssr: false,
prerender: ({getStaticPaths}) => [...getStaticPaths(), "/404", "/en/404", "/ja/404"],
} satisfies Config;
getStaticPaths()はルート設定に書かれた固定パスをすべて返します。後で説明するように、このブログはすべてのパスを明示的に書いているので、これだけで全ページが出力されます。/404、/en/404、/ja/404は言語ごとの404ページです。
loaderはビルド時にだけ実行される
React Routerのルートはloaderという関数でデータを受け取ります。本来はリクエストのたびにサーバーで実行されますが、ssr: false + prerenderではビルド時に一度だけ実行されます。結果は二か所に保存されます。
- ページのHTMLの中(最初にアクセスしたときに使われる)
<パス>.dataファイル(サイト内のリンクで移動するとき、ブラウザはこのファイルだけを取得する)
そのため、全記事のデータをloaderの中だけで扱えば、ブラウザ用のJavaScriptにはそのページに必要なデータだけが入ります。逆に画面のコンポーネントで全記事リストをimportすると、すべての記事がブラウザのバンドルに入ってしまうので注意が必要です。
一つの落とし穴は、loaderに渡されるURLです。移動用の.dataファイルを作るとき、リクエストURLは/x.data(トップページは/_.data)になります。そこで、URLから言語とパスを取り出す関数を別に作りました。
export function requestPath(request: Request): [Locale, string[]] {
const pathname = new URL(request.url).pathname.replace(/\.data$/, '').replace(/^\/_$/, '/');
const [locale, rest] = splitLocalePath(pathname);
return [locale, rest.split('/').filter(Boolean).map(decodeURIComponent)];
}
Next Metadata APIをmeta関数で作り直す
Next.jsでは、generateMetadataがオブジェクトを返すと、Nextがtitle、description、canonical、ogタグ、Twitterカードまで自動で作ってくれます。React Routerにはこの機能がなく、ルートのmeta関数がタグのリストを直接返す必要があります。
タグが一つ二つ違うだけでも検索結果に影響しうるので、Nextが出力していたHTMLと同じタグが出るように変換関数seoMetaを作りました。Next用に書いていたメタデータのオブジェクトはほぼそのままloaderへ移し、metaでこの関数を使ってタグに変換します。
// 記事ページのルート
export function loader({request}: LoaderFunctionArgs) {
const [locale, [slug]] = requestPath(request);
const found = postData[locale].allPostsMap.get(slug);
// ...
const metadata: Metadata = {
title: post.title,
description: post.description,
keywords: post.tags,
alternates: {canonical: `${SITE_URL}${post.url}`, languages: /* hreflang */},
openGraph: {type: 'article', title: post.title, /* ... */},
};
return {locale, metadata, post, jsonLd};
}
export const meta: MetaFunction<typeof loader> = ({loaderData}) => {
if (!loaderData) return [];
const tags = seoMeta(loaderData.locale, loaderData.metadata);
if (loaderData.jsonLd) tags.push({'script:ld+json': loaderData.jsonLd});
return tags;
};
seoMetaはNextがしていたことをそのまま真似します。タイトルの後ろに | Snc Studioを付け、openGraphがあればtwitter:cardも作ります。next-seoのArticleJsonLdで入れていた構造化データ(JSON-LD)はscript:ld+jsonのmetaに置き換えました。
移行後は、以前のビルド結果のHTMLと新しいHTMLの<head>を比べて、抜けているタグがないか確認するのがおすすめです。サイトマップ(next-sitemap)も、同じ形式のXMLを出すリソースルート(HTMLの代わりにファイルを返すルート)に置き換えました。
:slugではなく全パスを直接登録した理由
React Routerでは普通、記事ページを:slugのような動的パス一つで登録します。しかしこのブログでは、src/app/routes.tsで記事データを読み込み、すべてのパスを一つずつ登録しました。同じルートモジュールをidだけ変えて何度も登録する方法です。
function localeRoutes(locale: Locale): RouteConfigEntry[] {
const prefix = locale === DEFAULT_LOCALE ? '' : `${locale}/`;
const {allPostsMap, allPagedPostsMap, pagedCategoryPostMap, pagedTagPostMap} = postData[locale];
const routes: RouteConfigEntry[] = [];
const add = (path: string, file: string) => {
// ...
routes.push(route(`${prefix}${path}`, file, {id: `${locale}:${path}`}));
};
for (const slug of allPostsMap.keys()) add(slug, 'routes/post.tsx');
// 카테고리·태그·페이지 번호 경로도 같은 방식으로 추가
routes.push(route(`${prefix}*`, 'routes/not-found.tsx', {id: `${locale}:not-found`}));
return routes;
}
理由は404ページです。存在しないURLにアクセスすると、Cloudflare Pagesは404.htmlを返します。その後ブラウザでReactがそのURLでルートをもう一度探します(hydrate)が、:slugがあるとどんなURLでも記事ページにマッチしてしまい、404画面が「記事が見つかりません」という画面に変わってしまいます。
すべてのパスを直接書いておけば、存在しないURLは常に最後の*(残りすべてにマッチするsplatルート)にだけマッチします。そのため、事前に作った404画面がそのまま保たれます。prerenderに/404を別に入れたのも、このsplatルートをHTMLにするためです。
ただし欠点もあります。routes.tsは開発サーバーの起動時にしか読み込まれないので、新しい記事を追加したり、カテゴリやタグが増えたりしたら、開発サーバーを再起動する必要があります。
URLの維持:x/index.htmlをx.htmlへ
React Routerのprerenderは/xをx/index.htmlとして出力します。ところがCloudflare Pagesは、この場合/xを/x/へリダイレクトします。Next.jsはx.htmlを出力していたので、既存のURLは末尾に/がありませんでした。
そこで、ビルド後のスクリプトでx/index.htmlをx.htmlへ移動しました。この部分は長くなるので、Cloudflare Pagesで末尾スラッシュ(/)へのリダイレクトをなくす方法に別途まとめています。
package.jsonのビルドコマンドは次のようになりました。
"build": "bun scripts/contentlayer.ts && react-router build && bun scripts/postbuild.ts",
ContentlayerをNextなしで使う
ContentlayerはMDXの記事をJSONとJavaScriptに変換してくれるツールです。Next.jsではnext-contentlayerがビルドに組み込んでくれていましたが、これからは自分で実行する必要がありました。ここで三つの問題がありました。
CLIの代わりにスクリプトで変換する
ContentlayerのCLI(contentlayer build)は変換自体は成功するのに、最近のNodeでは終了時にprocess.exitCode関連のエラーを出して失敗扱いになりました。そこで、CLIのbuildコマンドがしている処理をscripts/contentlayer.tsで直接実行しました。
process.env.NODE_ENV = 'production';
import * as core from "@contentlayer/core";
import {pipe, T} from "@contentlayer/utils/effect";
const generate = () => pipe(
core.getConfig({configPath: undefined}),
T.chain(config => core.generateDotpkg({config, verbose: false})),
T.tap(core.logGenerateInfo),
core.runMain({tracingServiceName: 'contentlayer-cli', verbose: false}),
);
await generate();
開発中は--watchオプションを付けるとsrc/postsを監視し、記事が変わるたびに再変換するようにしました。
開発モードで変換するとReact 19で描画に失敗する
先頭のprocess.env.NODE_ENV = 'production'が重要です。ContentlayerがMDXをまとめるのに使うmdx-bundlerは、NODE_ENVがproductionでないと、Reactの開発用jsx-runtimeをMDXのコードの中に一緒に入れます。このコードがReact 19のproductionビルドと合わず、記事の本文が描画されませんでした。
productionで変換した結果は開発サーバーでも問題なく動くので、常にproductionに固定しました。
Viteがimport assertを読めない
Contentlayerが生成する.contentlayer/generated/index.mjsは、import ... assert {type: 'json'}という構文でJSONを読み込みます。これは現在with {type: 'json'}に置き換えられた古い構文で、Viteでは読めませんでした。そこでindex.mjsを経由せず、JSONファイルを直接importしました。型だけContentlayerが生成したものを使います。
import type {Post} from "@/contentlayer/generated";
import allPostsJson from "../../.contentlayer/generated/Post/_index.json";
const allPosts = allPostsJson as unknown as Post[];
相対パスにしたのは、このファイルをroutes.tsからも読み込むためです。routes.tsはViteが処理する前に実行されるので、@/のようなパスエイリアスが使えません。
その他の変更点
- MDXの描画:
next-contentlayer/hooksのuseMDXComponentと同じ実装を、20行ほどの小さな関数として自作しました。Contentlayerが生成したコード文字列をnew Functionで実行してReactコンポーネントを取り出す方式です。 - リンクボタン:Nextの
<Link legacyBehavior>の中にボタンを入れていた箇所は、ボタンにhrefとクリック時の移動を付けるLinkButtonコンポーネントに置き換え、同じDOMが出るようにしました。 - ルートのデータ:サイドバーのカテゴリ・タグの件数などの共通データはルートのloaderで一度だけ取得し、
shouldRevalidateがfalseを返すようにして、ページ移動のたびに再取得しないようにしました。 - ダークモード:
next-themesは名前に反してNextなしでも動くので、そのまま使いました。
削除したパッケージはnext、next-contentlayer、next-seo、next-sitemap、@next/mdx、eslint-config-nextなどで、Reactは18から19に上がりました。
移行してみて
外から見えるものは狙いどおりほぼそのままです。URL、メタタグ、サイトマップの形式を以前と同じにそろえたので、検索エンジンに登録済みのURLをそのまま使えます。内部ではNext専用のパッケージがすべてなくなり、Viteベースになって構成がシンプルになりました。
まとめると、サーバー機能を使わない静的ブログなら、React Router 8のssr: false + prerenderで十分に移行できます。ただし、次の三点は事前に押さえておくのがおすすめです。
- メタタグは自動では作られないので、移行前後のHTMLの
<head>を自分で比べます。 - Cloudflare Pagesのように
index.htmlの位置でURL末尾の/を決めるホスティングなら、ファイル構成を確認します。 - 存在しないURLに直接アクセスして、404ページが別の画面に変わらないか確認します。
言語ごとの自動移動はCloudflare Pages Functionsでブラウザの言語に合わせて自動で移動させる方法にまとめています。