この記事はシリーズ「動いているリポジトリを読む — Next.js 16 で学ぶクリーンアーキテクチャと TDD」の第 4 回(全 19 回)です。
実際に動いている公開リポジトリ kai-kou/gem-hunter(MIT)を1本まるごと読み解く連載です。架空のサンプルではなく実ファイルを引用し、掲載しているコマンド結果はすべて実際に動かして採取しています。
全体を通しで読みたい方へ: 同じ内容を 1 冊にまとめた Zenn Book を無料で公開しています。
シリーズ全体の目次
- 第 1 回 動いているリポジトリを読むという学び方
- 第 2 回 clone からテストが緑になるまで
- 第 3 回 要件IDとADRで、何を解くアプリなのかを地図にする
- 第 4 回 App Router の地図。フォルダがそのまま URL になる(この記事)
- 第 5 回 'use client' が6ファイルしかないアプリの境界の引き方
- 第 6 回 「クリーンアーキテクチャだから」を理由にしない層の分け方
- 第 7 回 ブランド型で「検証済みの値」を型にする
- 第 8 回 DIコンテナを使わない依存性逆転
- 第 9 回 外部APIの語彙を持ち込ませない翻訳層の作り方
- 第 10 回 依存規則を440行のPythonで機械検査する
- 第 11 回 URLのクエリが画面に出るまでを5層ぶん追跡する
- 第 12 回 ライブラリなしのi18nと、画面の変化を目で見ていない人へ伝える実装
- 第 13 回 どの層を何でテストするかを表で決めておく(公開予定)
- 第 14 回 失敗するテストを先に書いて Red→Green を1周する(公開予定)
- 第 15 回 「実ネットワークに出ない」を設定1行で保証する(公開予定)
- 第 16 回 外部APIに依存しないE2Eの組み方(公開予定)
- 第 17 回 赤くなったテストをどう判定するか、axeの限界とflakyの正体(公開予定)
- 第 18 回 生IPを残さないレート制限と、第三者HTMLを安全に表示する(公開予定)
- 第 19 回 CIは道具ではなくゲートの集合、そして自分のプロジェクトへの持ち帰り方(公開予定)
TL;DR
- App Router では フォルダ構成がそのままルーティング表 で、設定ファイルは存在しません
-
params/searchParamsは Promise。awaitが要ります -
notFound()を<Suspense>より後に置くと 404 を返せなくなります(エラーは出ません)
対象読者は、App Router を業務で書いたことがないエンジニアです。ファイル配置とルーティングの対応、そして「エラーが出ない壊れ方」を実例で押さえます。
URL とファイルの対応
App Router では、フォルダ名が URL のパスになり、ファイル名が役割を表します。
gem-hunter の app/ はこうです。
app/
├── globals.css
├── favicon.ico / icon.png
└── [locale]/
├── layout.tsx → 全ページ共通の外枠
├── page.tsx → /ja, /en(検索画面)
├── opengraph-image.tsx → SNS 共有用の画像
├── og-background-data.ts → 上の画像が使う背景データ(ルートにはならない)
├── gems/page.tsx → /ja/gems(Gem 一覧)
└── repos/[owner]/[repo]/
├── page.tsx → /ja/repos/facebook/react
└── not-found.tsx → 上記が 404 のときの画面
└── api/
├── search/route.ts → GET /api/search
└── auth/
├── login/route.ts → GET /api/auth/login
├── callback/route.ts → GET /api/auth/callback
└── logout/route.ts → POST /api/auth/logout
第 2 回で見たビルド出力と見比べてください。ファイル配置とルート一覧が 1 対 1 で対応しています。「どの URL でどの画面を出すか」を書いた表がどこにもない、というのが App Router の最初の驚きどころです。
主要なファイル名の意味
| ファイル名 | 役割 |
|---|---|
page.tsx |
その URL で表示される画面 |
layout.tsx |
配下の画面を包む外枠(ページ遷移しても再描画されない) |
not-found.tsx |
notFound() が呼ばれたときの画面 |
route.ts |
画面ではなく HTTP レスポンスを返すエンドポイント |
loading.tsx |
読み込み中の表示(gem-hunter では使っていません。理由は後述) |
error.tsx |
エラー時の表示(これも使っていません) |
角括弧のフォルダ([locale]、[owner]、[repo])は 動的セグメント で、URL のその位置の値がパラメータとして渡ってきます。
ルートレイアウトが app/layout.tsx にない
普通は一番外側のレイアウト(<html> タグを出す場所)を app/layout.tsx に置きます。gem-hunter にはそのファイルがありません。代わりに app/[locale]/layout.tsx が <html> を出しています。
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale: rawLocale } = await params
if (!isLocale(rawLocale)) {
notFound()
}
const locale = toLocale(rawLocale)
return (
<html lang={locale} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">{children}</body>
</html>
)
}
理由は <html lang="..."> です。この属性はスクリーンリーダーの読み上げ言語に直結するので、URL のロケールに応じて変える必要があります。ロケールを知っているのは [locale] セグメントの中なので、ルートレイアウトをそこへ下ろした、という判断です。
そしてこの配置が、後で not-found.tsx の実装を助けます。
params と searchParams は Promise
App Router で最初に戸惑うのがこれです。
params: Promise<{ locale: string }>
URL から取れる値なのに、受け取るには await が要ります。
const { locale: rawLocale, owner, repo } = await params
Next.js 15 以降でこうなりました。理由をざっくり言うと、「このページが URL のどの部分に依存しているか」をフレームワークが 実行してみるまで確定させない ためです。await した瞬間に「動的な値に依存している」と分かるので、キャッシュや事前生成の判断をそこで切り替えられます。
慣れるまでは、単に await を忘れると params.locale が undefined になる とだけ覚えておけば十分です。型が Promise なので、型チェックを走らせていれば気づけます。
検索して出てくる記事の多くは Next.js 14 以前のもので、params を await せずに使っています。バージョンを確認せずにコピーすると動きません。
metadataBase の罠
<head> は自分で書きません。metadata という名前でオブジェクトを export します。
export const metadata: Metadata = {
metadataBase: new URL(getSiteUrl()),
title: 'gem-hunter',
description: 'GitHub から埋もれた良質なリポジトリを見つける',
}
metadataBase には、コード中に長いコメントが付いています。
metadataBase未設定だとopengraph-image等の相対 URL 解決が既定のhttp://localhost:3000にフォールバックし、SNS クローラーが OG 画像を取得できなくなる(実デプロイの curl で確認済み)。
これは エラーが一切出ない不具合 の典型です。ビルドは通り、画面も正常に見えます。壊れているのは「SNS に貼ったときのプレビュー画像」だけで、開発中には気づけません。
not-found.tsx は params を受け取れない
詳細ページで対象が見つからないとき notFound() を呼ぶと、同じディレクトリの not-found.tsx が描画されます。ところがこのファイルには 仕様上 props が渡りません。params を受け取れないので、素直には「今どのロケールなのか」が分かりません。
解決策はこうです。
import { locale as getRootLocale } from 'next/root-params'
export default async function NotFound() {
const rawLocale = await getRootLocale()
const locale = tryLocale(rawLocale)
// ...
}
next/root-params(v16.3.0 で導入)は、ルートレイアウトが持っている動的セグメント の値を props 経由なしで取れる仕組みです。ここで「ルートレイアウトを app/[locale]/layout.tsx に置いた」ことが効いてきます。
さらに地味ですが重要なのが tryLocale() です。コメントに理由があります。
ルートパラメータの値は URL セグメントをそのまま返す(
isLocaleの再検証はされていない)ため、tryLocale()で不正値を既定ロケールへ倒す。
フレームワークから返ってきた値も検証する という姿勢です。tryLocale が何をするかは第 7 回で扱います。
notFound() を <Suspense> より前に置く
詳細ページの冒頭に、強い調子のコメントがあります。
<Suspense>は必ずnotFound()の後にのみ置く。取得結果がnullのときnotFound()で HTTP 404 を返す のが要件で、Suspense fallback が描画された時点でレスポンスヘッダが送出済みになり 404 を返せなくなる。
理屈はこうです。Suspense を使うと Next.js は「まず外側の HTML を送って、中身は後から流し込む」ストリーミングを始めます。HTML を送り始めた時点で HTTP ステータスは確定してしまう(200 として送られる)ので、その後に notFound() を呼んでも 404 にはできません。
画面上は 404 ページが出るのに、HTTP ステータスは 200。ブラウザで見ている限り気づけませんが、クローラーや監視には「正常なページ」として見えます。
これも エラーが出ない壊れ方 です。この制約のため、このページには loading.tsx を置いていません(loading.tsx は内部的に Suspense 境界を作るため)。
画面ではなく API を作る — Route Handler
route.ts を置くと、そのパスは画面ではなく HTTP エンドポイントになります。
export async function GET(request: NextRequest) {
if (!isAuthConfigured()) {
// 環境変数未設定時は静かに機能を無効化する。
// ログイン導線自体が表示されない前提だが、直接叩かれた場合の防御として 404 にする。
return NextResponse.json({ error: 'not_configured' }, { status: 404 })
}
const state = crypto.randomUUID()
const response = NextResponse.redirect(buildGithubAuthorizeUrl(state))
response.cookies.set(OAUTH_STATE_COOKIE_NAME, state, {
httpOnly: true,
secure: isSecureConnection(request.nextUrl.protocol),
sameSite: 'lax',
path: '/',
maxAge: OAUTH_STATE_COOKIE_MAX_AGE_SECONDS,
})
return response
}
HTTP メソッド名の関数を export するだけです。読みどころが 3 つあります。
- 環境変数が揃っていないときは 404 を返して静かに機能を止める。エラーで落とさず、機能ごと存在しないことにする
-
CSRF 対策の
stateをここで作り、短命 Cookie に入れている(第 18 回で照合側を見ます) - Cookie の名前は別ファイルで一元管理している。3 つの route handler が同じ名前を使うので、文字列を 3 箇所に書くと必ずずれるからです
なお app/api/auth/logout/route.ts は POST だけを export していて、GET を敢えて置いていません。これには痛い理由があり、第 17 回で扱います。
ロケールのリダイレクトを next.config.ts でやっている理由
/ にアクセスすると /ja へ飛ばされます。この処理は next.config.ts にあります。
const nextConfig: NextConfig = {
async redirects() {
return [
{ source: '/', destination: `/${DEFAULT_LOCALE}`, permanent: false },
{
source: buildLocaleRedirectSource(LOCALES),
destination: buildLocaleRedirectDestination(DEFAULT_LOCALE),
permanent: false,
},
]
},
}
こういう「全リクエストを見て振り分ける」処理は、普通は proxy.ts(Next.js 15 までの middleware.ts)に書きます。使わなかった理由がコメントにあります。
Next.js 16 で
proxy.tsは既定で Node.js ランタイム固定になり、runtimeconfig を proxy 側で上書きすることも不可(設定するとビルドエラーになる)ため、OpenNext Cloudflare アダプタ(Edge 実行)と両立できない。
これは教材リポジトリ固有の事情による選択です。 一般的な Next.js アプリで、ロケール判定やアクセス制御を proxy.ts に書くのは今でも標準的な方法です。gem-hunter が使っていないのは Cloudflare Workers 上で動かすためであって、「proxy.ts は避けるべき」という話ではありません。
落とし穴
-
next/imageを使っていない。画像最適化サーバーを持たない構成を選んだためで、これも教材固有の判断です。一般にはnext/imageを使うのが標準です -
error.tsxも置いていない。エラーを「例外として投げて画面ごと差し替える」のではなく、「値として受け取って画面の一部に表示する」設計にしているためです(第 11 回) -
'use server'(Server Actions)がリポジトリ全体で 0 件。検索フォームが素の<form method="get">で完結していてデータの書き込みがないからです(第 5 回)
まとめ
- フォルダ構成がそのままルーティング表。設定ファイルは存在しない
-
params/searchParamsは Promise。await が要る -
<html>を出すレイアウトを[locale]の下に置いたことが、next/root-paramsを使えることにつながっている -
notFound()は<Suspense>より前に置かないと 404 を返せなくなる(エラーは出ない)
シリーズの前後の記事
- ⬅️ 前の記事: 第 3 回 要件IDとADRで、何を解くアプリなのかを地図にする
- ➡️ 次の記事: 第 5 回 'use client' が6ファイルしかないアプリの境界の引き方
全 19 回をまとめて読みたい方は、同じ内容の Zenn Book(無料)へどうぞ。