Next.js + Vercel で日本語スラッグのページが全部404になる問題と対処法
ローカルでは動くのに本番だけ404。原因は generateStaticParams の二重エンコードだった。
ローカルでは表示できるページが、Vercelにデプロイすると404になる。落ちているページの共通点はURLに日本語が含まれていることだった。
事象
/blog/ギターの話のような日本語パスのページが、本番環境でのみ404- ローカルは
npm run devでもnpm run build && npm run startでも正常 - Vercelのログに目立つエラーは出ない
- 静的生成されているはずなのにアクセスすると404
原因:generateStaticParams の二重エンコード
generateStaticParams でスラッグを生成する際、または動的ルートのパス解決で、値のエンコードとURLのエンコードが二重になっている。
// ❌ 問題のあるコード
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({
slug: encodeURIComponent(post.slug), // ← これが二重エンコードの原因
}));
}
App Router は generateStaticParams の返り値をビルド時にそのままパスとして使い、内部でもう一度エンコードする。事前に encodeURIComponent した値を渡すと二重になる。
ギター の場合:
encodeURIComponent('ギター')→%E3%82%AE%E3%82%BF%E3%83%BC(1回目)- Next.js が再エンコード →
%25E3%2582%25AE%25E3%2582%25BF%25E3%2583%25BC(2回目)
生成ファイルのパスとブラウザの実際のリクエストパスが食い違い404になる。next dev はこのズレをファジーに吸収するのでローカルでは気づけず、本番は静的ファイルをそのまま配信するため完全一致しないと落ちる。
解決策
generateStaticParams には生の未エンコード値を返す。
// ✅ 正しいコード
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({
slug: post.slug, // そのまま返す
}));
}
page.tsx 側で params を受け取るときも同じ。生の値で渡ってくるので decodeURIComponent は不要(やるとローカルで壊れる)。
export default async function Page({
params,
}: {
params: { category: string; slug: string };
}) {
// params.slug は生の値で来る(例:'ギター')
const post = getPostBySlug(params.slug); // そのまま検索キーに使える
}
スラッグがファイル名由来の場合は、fileToSlug 等の変換関数の中で encodeURIComponent を呼んでいないか確認する。
generateStaticParams まわりの整理
| 場所 | やること |
|---|---|
generateStaticParams の返り値 | 生の値そのまま。エンコード不要 |
page.tsx の params を使う側 | 生の値として受け取る。デコード不要 |
リンクの href に日本語スラッグ | 生の値でいい。Link が処理する |
Next.js がエンコード・デコードを自動でやる。人間が手を出すと壊れる。
補足:ファイル名をスラッグに使う場合
Markdownのファイル名(例:ギターの話.md)をそのままスラッグにしていると同じ問題を踏む。対処は2択。
- ファイル名をASCIIにする:
guitar-no-hanashi.mdのように英数字のみ。タイトルはfrontmatterに日本語で別途書く - スラッグ変換時にASCIIへ寄せる:日本語を除去/ローマ字化してスラッグはASCIIで扱う
特に理由がなければ、最初からファイル名を英数字ハイフンで統一しておくのが後の問題を避ける一番の近道。frontmatterに slug をASCIIで別途持つ設計でもいい。