なぜ Pages Router を選んだか
App Router を採用しなかった。理由は単純で、個人サイトに RSC(React Server Components)の認知コストを払う価値がまだ薄い から。
私のサイトは:
- 記事は SSG + ISR でほぼ全部静的化できる
- ログインユーザは Admin 画面の自分だけ
- レイアウトはフラット(ネスト 2 階層)
これに対して App Router は:
- Server / Client の境界に常に意識を向ける必要がある
metadataAPI への移行学習コスト- ライブラリ互換性のばらつき(特に Next 16 直後)
費用対効果が見合わない。Pages Router は getStaticProps + getServerSideProps のシンプルな概念で十分。Next.js 16 でも継続サポートされている。
App Router を選ぶべきは、認証ありのプロダクトで多段階のサーバ取得 + ストリーミングが価値を生む場合。個人ブログは違う。
Tailwind v4 の @theme で semantic トークン体系
v4 で tailwind.config.js がほぼ廃止され、CSS の @theme ブロック側にトークンを書くスタイルになった。これがよくできていて、JS 設定への往復が減る のは想像以上に体験が変わる。
私は次の名前空間でトークンを定義した:
@theme {
/* 背景階層 */
--color-paper: #FAFAF7;
--color-paper-alt: #F1F1EC;
--color-paper-sunken: #E8E8E2;
/* 文字階層 */
--color-ink: #0E0E0E;
--color-ink-2: #3A3A38;
--color-ink-muted: #6E6E6A;
/* 罫線 */
--color-rule: #1F1F1D;
--color-rule-soft: #D9D9D2;
/* 5 部門カラー */
--color-cat-engineering: #2D5BFF;
--color-cat-design: #FFB020;
--color-cat-ai: #00A86B;
--color-cat-process: #A66B3A;
--color-cat-essay: #E63946;
}
これで bg-paper text-ink-muted border-rule-soft bg-cat-engineering などが自動生成される。Reader / Admin 両方で同じトークンを共有 できるので、命名空間を分けなくていい。
落とし穴を 1 つ。max-w-prose だけは Tailwind の built-in(65ch)が勝つ 。--container-prose: 42.5rem を @theme に書いても、なぜか max-w-prose は上書きされない。素直に max-w-[42.5rem] のような arbitrary value で書く方が確実だった。
next/font の preload は CJK で外す
7 種類のフォントを next/font/google でセルフホストしている:
- Bricolage Grotesque(display, variable wdth/opsz)
- Instrument Sans(body, variable)
- Fraunces(serif italic, SOFT/WONK 軸付き)
- Zen Kaku Gothic New(CJK display)
- Noto Sans JP(CJK body)
- Noto Serif JP(CJK serif)
- JetBrains Mono(mono)
Latin 系は preload あり、CJK 系は preload なし が正解。日本語フォントは subset にしても 5MB 以上あるので、どのページにも全 weight が並ぶと初回 LCP が壊れる。
const fontNotoJp = Noto_Sans_JP({
weight: ['400', '500', '700'],
variable: '--ff-noto-jp',
display: 'swap',
preload: false, // ← ここが重要
});
display: swap と組み合わせると、英文が即座に出て日本語フォントは読み込み完了後に置き換わる 動きになる。FOIT より体感は良い。
ISR + revalidate は最初から組む
記事公開時に Next.js の ISR を即座に再生成する仕組みを最初から組んだ。これをあとから入れるのは大変なので、最初の 1 日でやる方がよい。
仕組みは単純:
# Rails 側、Post 保存後の after_commit
class Post < ApplicationRecord
after_commit :enqueue_revalidate, if: :saved_change_to_status?
def enqueue_revalidate
RevalidatePostsWorker.perform_async(slug)
end
end
# RevalidatePostsWorker
class RevalidatePostsWorker
include Sidekiq::Worker
def perform(slug)
HTTP.headers('Authorization' => "Bearer #{ENV['NEXT_REVALIDATE_SECRET']}")
.post("#{ENV['NEXT_PUBLIC_URL']}/api/revalidate", json: { paths: ["/posts/#{slug}", "/", "/posts"] })
end
end
// frontend/pages/api/revalidate.ts
export default async function handler(req, res) {
if (req.headers.authorization !== `Bearer ${process.env.NEXT_REVALIDATE_SECRET}`) {
return res.status(401).end();
}
const { paths } = req.body;
await Promise.all(paths.map((p) => res.revalidate(p)));
res.json({ revalidated: true });
}
これで「Admin で公開ボタンを押す → 数秒以内にトップページと記事ページが更新される」流れができる。
SSR API URL と CSR API URL の使い分け
これは Next.js + Rails を Docker で動かしている人が必ず詰まるところ。
- ブラウザから叩く URL:
http://localhost:2001/api/v1 - SSR /
getStaticPropsから叩く URL:http://backend:3000/api/v1
Docker Compose の networking では、コンテナ内の別サービスは host=service_name port=internal_port でアクセスできる。localhost:2001 を Next.js コンテナの内側で使うと届かない。
私は Axios クライアントで分岐させた:
const baseURL =
typeof window === 'undefined'
? process.env.NEXT_PUBLIC_SSR_API_URL || 'http://backend:3000/api/v1'
: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:2001/api/v1';
typeof window === 'undefined' が SSR 判定のお決まり。これを忘れると、dev では動くが prod で 500 が返る 罠にハマる。
まとめ
実運用で初めて気づいたことを、5 つに圧縮するなら:
- Pages Router は今でも妥当な選択肢。App Router が万能とは限らない。
@themeベースの semantic トークン は Reader / Admin 両方で再利用が効く。- CJK フォントは preload を切る。サイズが桁違い。
- ISR + revalidate は最初の日に組む。後付けは難しい。
- API URL の SSR / CSR 分岐は必須。typeof window で判定。
この 5 つを最初に押さえると、Next.js + Rails の個人サイトは半日で安定運用に入れる。
ところで、これらは全部 Claude Code とのセッションで気づいた。AI に書かせた素のコードからではなく、AI と話しながら本番に出す過程で出てきた。
実装の落とし穴は、結局のところ「動かしてみないと見えない」場所にある。