ポートフォリオをNext.jsからTanStack Start + Cloudflareに移行した
このポートフォリオサイトをNext.js 14 (App Router) + VercelからTanStack Start + Cloudflare Workersに移行した。その記録をまとめておく。
移行の動機
特に大きな不満があったわけではないが、TanStack Startに興味があり試してみたかった。Cloudflareは料金面での魅力もあり、合わせて移行を決めた。
移行前の構成
- フレームワーク: Next.js 14 App Router
- デプロイ: Vercel
- 主要ライブラリ:
@vercel/og(OG画像)、@t3-oss/env-nextjs(環境変数)、zenn-markdown-html(Markdownパース)
TanStack Startについて
TanStack Startは、TanStack Routerをベースにしたフルスタックフレームワーク。ビルドツールとしてvinxiを使っており、ViteベースなのでViteエコシステムの恩恵をそのまま受けられる。
データフェッチはRSC(React Server Components)ではなく、ルートのloaderとサーバー関数(createServerFn)で行う。
詰まったポイントと解決策
ファイルシステムアクセスができない問題
最大の課題はMarkdownファイルの読み込み。元の実装はfs.readdirSyncやfs.readFileSyncでファイルを読んでいたが、Cloudflare Workersではランタイムでのファイルシステムアクセスができない。
解決策: Viteのimport.meta.globを使ってビルド時にバンドルする。
const rawFiles = import.meta.glob("../_posts/*.md", {
query: "?raw",
import: "default",
eager: true,
}) as Record<string, string>;
import.meta.globはViteがビルド時に展開してくれるため、ランタイムでのファイルシステムアクセスが不要になる。gray-matterやzenn-markdown-htmlも含めてすべてWorkerバンドルに含まれる。
@vercel/og が使えない問題
OG画像生成に使っていた@vercel/ogはVercel専用。Cloudflare WorkersではSVG生成ライブラリのsatoriとWASMベースのSVG→PNG変換ライブラリ@resvg/resvg-wasmを組み合わせて代替した。
import satori from "satori";
import { Resvg, initWasm } from "@resvg/resvg-wasm";
const svg = await satori(<div>...</div>, {
width: 1200,
height: 630,
fonts: [{ name: "custom", data: fontData, style: "normal" }],
});
const resvg = new Resvg(svg, { fitTo: { mode: "width", value: 1200 } });
const png = resvg.render().asPng();
ルーティングの移行
Next.js App RouterとTanStack Startのルーティングは構造が似ているが記法が異なる。
| Next.js | TanStack Start |
|---|---|
app/layout.tsx |
routes/__root.tsx |
app/page.tsx |
routes/index.tsx |
app/posts/[slug]/page.tsx |
routes/posts/$slug.tsx |
app/api/og/route.tsx |
routes/api/-og.tsx |
APIルート(createAPIFileRoute使用)はTanStack Routerのルートツリーに含まれないため、ファイル名に-プレフィックスをつけることで警告を消せる。
データフェッチの移行
Next.jsのRSCでのデータフェッチは、TanStack StartではルートのloaderとcreateServerFnで代替する。
// Next.js (RSC)
export default async function Page() {
const data = await fetchSomething();
return <Component data={data} />;
}
// TanStack Start
const fetchSomething = createServerFn({ method: "GET" }).handler(async () => {
return fetchSomething();
});
export const Route = createFileRoute("/")({
loader: () => fetchSomething(),
component: () => {
const data = Route.useLoaderData();
return <Component data={data} />;
},
});
createServerFnのバリデーション用メソッドはドキュメントによっては.validator()と書いてあるが、インストールしたバージョン(1.166.2)では.inputValidator()が正しかった。バージョンによって異なるようなので注意が必要。
環境変数
Next.jsのNEXT_PUBLIC_*プレフィックスはViteのVITE_*プレフィックスに変更。@t3-oss/env-nextjsは@t3-oss/env-coreに置き換えた。
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const env = createEnv({
clientPrefix: "VITE_",
client: {
VITE_SITE_URL: z.string().url(),
VITE_SITE_TITLE: z.string(),
VITE_GA_ID: z.string(),
VITE_TWITTER_USER_NAME: z.string(),
},
runtimeEnv: import.meta.env,
});
next/link・next/imageの置き換え
next/linkはTanStack RouterのLinkコンポーネントに、next/imageは通常の<img>タグに置き換えた。Cloudflare PagesにはVercelのような自動画像最適化機能はないため、必要であれば別途対応が必要。
// Before
import Link from "next/link";
<Link href="/about">About</Link>
// After
import { Link } from "@tanstack/react-router";
<Link to="/about">About</Link>
スキャフォールドについて
TanStack Startは@tanstack/cliでスキャフォールドできる。Cloudflareアダプターも--deploymentオプションで指定できる。
pnpm dlx @tanstack/cli@latest create my-app \
--framework React \
--deployment cloudflare \
--package-manager pnpm
生成されたプロジェクトにはwrangler.jsoncが含まれており、nodejs_compatフラグもデフォルトで有効になっている。pnpm deployでそのままCloudflare Workersにデプロイできる。
まとめ
TanStack Startはまだ発展途上のフレームワークだが、Viteベースで動作が速く、ルーターの型安全性も高い。Cloudflare Workersへのデプロイもシンプルに設定できた。
ただし、ファイルシステムアクセスやNode.js固有のAPIに依存している箇所は移行時に注意が必要。今回はMarkdownの読み込みをimport.meta.globでビルド時バンドルに変換することで対処した。
