profile image

ポートフォリオをNext.jsからTanStack Start + Cloudflareに移行した

TanStack StartCloudflareNext.js

このポートフォリオサイトを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.readdirSyncfs.readFileSyncでファイルを読んでいたが、Cloudflare Workersではランタイムでのファイルシステムアクセスができない。

解決策: Viteのimport.meta.globを使ってビルド時にバンドルする。

libs/posts.ts
const rawFiles = import.meta.glob("../_posts/*.md", {
  query: "?raw",
  import: "default",
  eager: true,
}) as Record<string, string>;

import.meta.globはViteがビルド時に展開してくれるため、ランタイムでのファイルシステムアクセスが不要になる。gray-matterzenn-markdown-htmlも含めてすべてWorkerバンドルに含まれる。

@vercel/og が使えない問題

OG画像生成に使っていた@vercel/ogはVercel専用。Cloudflare WorkersではSVG生成ライブラリのsatoriとWASMベースのSVG→PNG変換ライブラリ@resvg/resvg-wasmを組み合わせて代替した。

routes/api/-og.tsx
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ではルートのloadercreateServerFnで代替する。

// 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に置き換えた。

env.ts
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でビルド時バンドルに変換することで対処した。