Next.js 15(App Router)+ React 19 の入門記事です。
まず「コードがどこで動くのか」を押さえ、そこからデータ取得・URL・Server Actions が なぜその形になるのか をたどります。書き方の手順書ではありません。
第1章 まず地図:Next.js のコードは2つの場所で動く
Next.js でいちばん最初に腹落ちさせるべきなのは、文法でもディレクトリ構成でもなく、「自分が今書いているコードは、どこで動くのか」 です。ここが曖昧なままだと、以降の章の判断(どこで取るか・どこに状態を置くか・どこで検証するか)が全部ぼやけます。
1-1 JavaScript は、もともとブラウザの中でしか動かなかった
なぜ Next.js では「サーバーで動くコード」と「ブラウザで動くコード」が同じ言語で書けるのか。理由は Next.js の設計思想より、JavaScript という言語がたどった歴史のほうにあります。
「速いから、ブラウザの外へ持ち出そう」で世界が変わった
JavaScript はブラウザを動かすための言語として生まれました。実行環境はブラウザ1つだけ。サーバーは Perl や PHP や Java の担当で、両者は別の言語で書くのが当たり前でした。そこから JS がブラウザの外へ持ち出されるまでが、次の流れです。
- 1995 JavaScript 誕生
Netscape の Brendan Eich がわずか10日で設計した、ブラウザ内の小さな飾りつけ用の言語。実行環境はブラウザだけ。- 2008 V8 エンジン
Lars Bak らのチームが作った V8 を Google が Chrome に載せる。JS を機械語にコンパイルして走らせるようになり、桁違いに速くなった。- 2009 Node.js
Ryan Dahl が「この速いエンジン、ブラウザから取り出して単体で動かせばよくない?」と V8 を抜き出し、サーバーで動く JS 実行環境に仕立てた。- 2010s 両側が同じ言語に
npm とビルドツールが育ち、サーバー側も JS で書くのが普通になる。※ 厳密には 90 年代からサーバー用 JS の試みはありましたが、普及したのは V8 と Node.js 以降です。
つまり今の Next.js には、JavaScript の実行環境が2つあります。ブラウザに載っている JS エンジンと、サーバー側で動く JS のランタイム(Node.js、あるいは Edge ランタイム)。同じ言語ですが、別の場所で動く別のプロセスです。
同じ言語 ≠ 同じ環境
出自が違うので、置いてある道具も違います。window・DOM・localStorage はブラウザにしかなく、ファイル・DB 接続・秘密の環境変数はサーバーにしかない。「同じ JS だから、どこでも同じことができる」わけではありません。ここが最初の落とし穴です。
RSC=React Server Component。サーバー側だけで動き、完成した HTML を返す部品。 ブラウザに JavaScript を送らないので軽い。ここが出発点です。
そして App Router の新しさは、部品ごとに実行場所を選べるようになったこと。サーバーで書けること自体は、Node.js の時代からできていました。選べるということは、毎回選ばなければならないということでもあります。その選び方が、この記事の主題です。
1-2 サーバーJS と ブラウザJS は「並列」の関係
同じ TypeScript で、同じリポジトリの、隣り合ったファイルに書かれている。それでも実行される場所は2つに分かれています。しかもこの2つは、どちらかがどちらかを含む「親子」ではなく、**担当の違う「兄弟」**です。
┌─ ブラウザ JS ─┐ ┌─ Next サーバー ─┐
│ 画面・入力状態 │ │ 検証・保存・DB │
└───────────────┘ └──────────────────┘
片方が片方を含む「親子」ではない。担当が違うだけの横並び。
| ブラウザ | Next.js サーバー | |
|---|---|---|
| 動くもの |
"use client" の部品 |
Server Component が HTML を作る |
| できること | ユーザー操作・入力状態を持つ | DB・秘密鍵・外部 API に触れる |
| コスト | 追加の JS を積む(重くなりうる) | 追加の JS を積まない(軽い) |
どのコードがどっちで動く?
create-next-app のコードは1つですが、実際には "use client" を付けたファイルと、それが import する依存だけ がブラウザに送られて動きます。それ以外は Next サーバーの中だけで動く。この仕分けはビルド時に Next.js がやってくれるので、書き手は「どっちに置くか」だけを決めます。
並列だと分かると何が変わるか
「サーバー側の値をブラウザ側から直接読む」「ブラウザの window をサーバーで触る」といった発想が最初から出てこなくなります。2つの間を渡れるのは props で降ろす値と、呼び出し(後述の Server Action)だけ。境界を越えるには必ず「渡す」動作が要る、と分かるからです。
本記事での言い換え
「server で取る/組む」=Next.js サーバー側で完結。「client で〜」="use client" を付けたブラウザ側の部品で行う、という意味で使います。
第2章 サーバーで画面を組み立てる
第1章の「サーバー側」で何が起きるか。データをどこで取り、どこで解決し、どこにキャッシュし、条件をどう受け取るか。App Router の既定の作法を決めます。
2-1 完全SSR:いちばん軽い作り方を既定にする
むかしは、データ取得は「全部ブラウザの仕事」だった
少し前まで、React の画面は まずカラで表示 → ブラウザで
useEffectから API を叩く → 返ってきたら描き直す のが定番でした。だから最初の一瞬は空っぽ=くるくるスピナー、が当たり前。でも 1-1 のとおり、サーバーでも JS は動く。ならデータを入れ終わった HTML を最初から返せばいい。この章の「完全SSR」は、その一番素直な形です。
「完全SSR」は、データ取得も解決・描画も全部サーバーで終わらせて、出来上がった HTML をブラウザへ渡す形です。ブラウザに送る JS が増えないので軽く、これを既定(デフォルト)にします。
ポイントは、page.tsx は取得を開始するだけで待たない(await しない)。待つのは中の部品の役目です。待っている間は Suspense がスケルトンを見せます。
// page.tsx —— 取得を「起動」して Promise を渡す(await しない)
<Suspense fallback={<ItemsSkeleton />}>
<ItemsSection promise={fetchItems()} />
</Suspense>
// ItemsSection —— サーバー側で await して解決・描画する(完全SSR)
const items = await promise
if (!items) notFound() // next/navigation。サーバーでそのまま 404 へ
return <ItemList items={items} />
Suspense とは
「まだ届いていないデータ」の間だけ仮の見た目(スケルトン)を自動で出す React の仕組み。データが来たら本物に差し替わる。
完全SSR なら Loading は要らないのでは?
「完成 HTML が届くなら Loading なんて無いのでは」。よくある疑問です。
答えは 「待ちは消えていない。“白い画面の待ち”に化けているだけ」。本当に Loading が要らないのはデータが速い/キャッシュ済み(SSG・ISR)で待ちがゼロのとき。「完成 HTML が即届く」はこの場合に成り立ち、スケルトンは不要。遅い取得があるときだけ、白い待ちを避けてスケルトンを置きます。
スケルトンを出すかどうかの判断
まず 「あとで中身が入って下がずれるか(CLS)」 で決めます。ずれるなら場所取り(同寸スケルトン or 高さ確保)。ずれないなら軽い表示で十分で、そもそも待ちが無ければ何も要りません。
解決の仕方は3つある(既定は完全SSR)
じつは「取得したデータをどこで解決するか(値を取り出して描画に渡すか)」には3つのやり方があります。完全SSR を既定にし、残り2つは「ブラウザでないと無理な理由があるとき」の例外として選びます。
◎ 完全SSR(既定)
取得も解決も全部サーバー。追加 JS を積まず軽い。エラー遷移もサーバーの notFound() / redirect()(next/navigation の Next.js 機能)がそのまま効く。
○ client のみ解決(await-on-client)
取得はサーバー、解決だけブラウザ(use())。データを“描く部品”がブラウザ必然のとき。
例:取得済みデータをグラフ/地図/カルーセルなど client ライブラリで描画する/タブ・アコーディオンで開閉しながら見せる。
△ 完全client(client フェッチ)
取得も解決もブラウザ(useEffect + fetch / SWR / TanStack Query)。サーバーでは起動できない、操作・時間が起点の取得だけ。
例:「もっと見る」の追記取得・無限スクロール/在庫や通知の定期ポーリング/「更新」ボタンでの再取得。
迷ったら完全SSR
「ブラウザ側で解決/取得したい理由」を1つも言葉にできないなら、完全SSR のままにする。3つの違いは**「どこで取り、どこで解決するか」だけ**。
2-2 「数珠つなぎ(ウォーターフォール)」を避ける
ウォーターフォール=取得が1本ずつ順番待ちになって遅くなること。 原因は「どこで取り始めるか」。子の部品の中で取得を始めると、親の描画がそこに届くまで始まらず、直列になります。
// ✗ 直列:A を待ってから B が始まる(遅い)
const a = await fetchA()
const b = await fetchB()
// ✓ 並列:両方すぐ起動して、Promise のまま渡す(速い)
const aPromise = fetchA()
const bPromise = fetchB()
ルール
取得の起動は一番上(page / layout)に集める。子は受け取った Promise を解くだけ。
※「B は A の結果が必要」という本物の依存だけは順番待ちが正当なので、各セクションを別々の Suspense で包んで独立に流す。
2-3 「重複呼び出しの節約」と「焼き付き」
React の cache() は、同じ画面を作る間に同じ関数が何度呼ばれても、実際の通信を1本にまとめる道具(ブラウザのキャッシュとは別物)。ヘッダーとページで同じ API を使うときに巻きます。
// 何回呼んでも、1リクエストの間の実際の通信は1本にまとまる
import { cache } from "react"
export const getCategories = cache(
() => fetchCategories({ cache: "force-cache" }), // 中身は fetch する関数
)
焼き付きに注意
ページ全体がキャッシュされる設定だと、取得に失敗したときのフォールバック表示までキャッシュに焼き付く(API が直っても古い表示が出続ける)。その場合はその部分だけブラウザ側の取得へ逃がす(キャッシュされた HTML の中で、そこだけ毎回取り直す)。
第2章の要点
迷ったら完全SSR。取得の起動は上に集約。「client にしたい理由」を1つも言えないなら server のまま。
2-4 どこへ取りに行くか(データの居場所で決まる)
ここまでは「どこで解決するか」の話でした。それとは別に「データがどこにあるか」という軸があります。自己ホスト=Next の中で完結する(DB を直接読む、プロセス内のロジックで済む)/外部バックエンド=別リポジトリの API サーバーがある の2つ。この掛け算で、宛先の叩き方が決まります。
| 解決パターン | 自己ホスト(Next でバックエンド) | 外部バックエンド |
|---|---|---|
| 完全SSR | 関数を直接呼ぶ(HTTP を経由しない) | サーバー側で fetch(API_URL + …)
|
| SSR起動 → client解決 | 直接呼び、Promise を client へ渡す | サーバー側で fetch し、Promise を渡す |
| 完全client | 自分の Route Handler へ相対パス |
NEXT_PUBLIC_API_URL(例外・下記) |
環境変数は1本でいい
origin を指す設定は NEXT_PUBLIC_API_URL(外部バックエンドのドメイン)だけで足ります。「自分の URL」を指す環境変数は要りません。server から自分を呼ぶときは関数を直接呼ぶので URL が要らず、client から自分を呼ぶときは相対パスでブラウザが解決してくれるからです。
よくある誤解:Next でバックエンド=API ルート不要
そうではありません。RSC(サーバー側)が HTTP で叩かないだけで、client から取るなら Route Handler は必要です。そして Route Handler は誰からでも叩ける公開の入口なので、そこでは自分でもう一度チェックします(3-6 の「真ん中を作る」と同じ話)。
右下だけは例外扱い
「完全client × 外部バックエンド」=ブラウザから外部 API を直叩きする形は、鍵が要らない・CORS 済み・返り値を信頼できるが全部揃うときだけ。1つでも欠けたらサーバー経由にします(3-6)。
2-5 取得の条件は URL から来る(searchParams)
ここまでは「サーバーが何を返すか」の話でした。最後に「サーバーは何を受け取るか」を見ます。App Router では、ページの Server Component は searchParams(URL の ?q=…&page=2)を props で受け取ります。URL が変わればサーバーがもう一度走り、データを取り直す。つまり URL は Server Component への入力です。
つい
useStateに持ちたくなる。でも、それだと壊れる「絞り込みの条件は画面の状態だから
useState」。自然な発想ですが、これをやると結果を共有できない・リロードで消える・戻る/進むが効かない。URL が/itemsのまま中身だけ変わるからです。しかもサーバーは条件を知らないので、取得はブラウザ側でやり直すしかなくなります。
置き場所の原則
「再現したいものは URL、一時的なものだけローカル」。確定した条件(キーワード・並び順・ページ番号)は URL に置き、入力途中の文字や機微な情報はローカルに留める(URL はログや Referer に残るため)。
ただし searchParams は誰でも手で書き換えられます(?page=abc、範囲外、重複)。あちこちに Number(page) || 1 を書き散らさず、受け取り口で1枚のスキーマに通します。
const schema = z.object({
q: z.string().trim().default(""), // 無ければ空文字
page: z.coerce.number().int().min(1).default(1), // "abc" や -5 は弾く
})
const parsed = schema.safeParse(await searchParams)
if (!parsed.success) redirect("/items") // 黙って既定に倒さず、正しいURLへ直す
こっそり握りつぶさない
不正値を黙って既定に倒すと、URL の打ち間違いに誰も気づけません(サイレント故障)。redirect で正しい URL に書き直せば、アドレスバーの表示と画面の中身が必ず一致します。
読み取りの入口
読み取りの条件は URL からサーバーへ 入ってくる。次の章の書き込みは逆に、ブラウザからサーバーを呼ぶ。向きが反対になります。
第3章 境界を「関数」で越える
第1章で見た2つの実行場所を、Next.js はどう繋いだのか。書き込み(作成・更新・削除)はここに集約されます。
この章の地図
第3章は前提(Server Action の仕組みと conform)の説明が半分を占めます。難所はこのプラットフォーム理解のほうで、書き込み固有のルールはむしろ少数:① どこから叩くか(秘密・CORS)/② 検証は2段(server が権威)/③ 失敗は throw せず入力保持/④ 非冪等=二重送信防止。この4つを押さえれば、あとは一本道です。
3-1 読み取りと書き込みは、求めるものが正反対
なぜ読み取りと分けて考えるのか
取得(read)は「何度でも同じ結果を返す・URL に載せて共有したい」。でも送信(mutation)は「一度きり・戻せる形にしたい・共有したくない」。求めるものが正反対なので、状態の置き場所も道具も逆になります。ここを混ぜると事故ります。
「送信直後の画面」を共有・リロード復元したい人はいません。だから結果(成功可否・エラー・送信中)は URL でなくフォームの一時状態に置きます。
3-2 Server Action:関数に見えるサーバー処理
1-2 の地図を思い出してください。**ブラウザ JS と Next のサーバーは「並列の兄弟」**で、担当が違うだけの横並びでした。ここにもう1本の線を引くと、Server Action が腑に落ちます。
構造は「兄弟」(1-2)。でもフロー(送信)には「向き」がある。
ブラウザ ──呼び出し(裏は隠れHTTP)──▶ Next サーバー(ここで実行)
◀──────────── 戻り値 ────────────
Server Action はどっちで動く?
1-2 のとおり、ブラウザに送られるのは "use client" を付けたファイルとその依存だけ。Server Action はサーバー側にしか存在しません。ブラウザ側に見えているのは「その関数を呼ぶための参照」であって、関数の中身ではない。だから Server Action の中に秘密鍵を書いても漏れません。
Server Action は、この「呼び出し → 戻り」の境界を、関数呼び出しの見た目のまま越える仕組みです。
Server Action="use server" を付けた関数。 ブラウザからはただの関数呼び出しに見えるのに、実体は Next のサーバー側の処理を呼び出し、それがサーバーで実行されます。裏の通信は Next が組むので、こちらは URL も fetch も書きません。
なぜ「画期的」なのか(と、なぜ初見でわかりにくいのか)
従来は API Route を定義し、それを叩く fetch(リクエスト処理)を書く、という手順が要りました。この形では「ブラウザが API を呼ぶ → (Next の)サーバーが処理して返す → ブラウザが受け取る」という通信のフローが常に目に見えていた(いかにも手続き=命令型)。
Server Action はただ関数を呼ぶだけ。これは「ブラウザ ⇄ サーバー」の境界をあたかも飛び越え、シームレスに繋いでいるように見えます。React が関数型(宣言的)であることを踏まえると、従来の“手続き的なフロー”を意識させずに書けるのは画期的。
ただし、裏で通信が起きている事実が隠れるぶん、初見では「なぜこれで動く?」と分かりにくいという副作用もあります。
| 従来:API Route + fetch | Server Action | |
|---|---|---|
| 書くもの | エンドポイントを定義 → fetch("/api/...") を手書き → JSON を組み立て/解析 |
"use server" 関数を <form action> に渡すだけ |
| 型 | 型は自分で合わせる。境界で型が切れる | 戻り値の型はそのまま画面まで繋がる |
従来:配管を手書きする(比較用)
ルート定義と fetch の2ファイルに分かれ、境界で型が any に切れます。
// ① route.ts(サーバー:エンドポイント定義)
export async function POST(req: Request) {
const data = schema.parse(await req.json()) // body は any → 自分で検証
return NextResponse.json({ ok: true }) // …保存して返す
}
// ② contact-form.tsx(ブラウザ:fetch 手書き)
"use client"
const res = await fetch("/api/contacts", { // URL・method・header を手書き
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
const json = await res.json() // ← 型は any(境界で切れる)
Server Action:同じことを関数で
やること(検証 → 保存 → 結果を返す)は同じ。違うのは書き方だけで、ルート定義も fetch も消え、関数を <form action> に渡すだけ。戻り値の型はそのまま画面まで繋がります。
"use server" // ←「この関数はサーバーで動く」目印
export async function submitContact(prev, formData: FormData) {
const parsed = schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) return { errors: parsed.error.flatten() } // レスポンスでなく戻り値で返す
// …保存…
return { ok: true }
}
"use client"
<form action={submitContact}>…</form> // fetch を書かない。Next が裏で通信を組む
裏で起きていること
submitContact は 「Next で書く API 層を、関数にしたもの」。従来なら「ルートを定義してエンドポイントを作る」作業を、Next.js が関数呼び出しの裏で肩代わりしている。本質は API Route を切るのと同じで、違うのは誰が書くかだけ。
外部 API を呼ぶときは「Next 側にあえて API を作る」感覚になります。Server Action の中でさらに別リポジトリの外部 API(Laravel/Java 等)を呼ぶ場合、これは Next の中に薄い API 層(BFF)を置いているのと同じ。
ブラウザ ──▶ Server Action(Next 側の API) ──▶ 外部 API
だから client 側に「受け皿」を置く ── フック三兄弟 + conform
Server Action は「関数」。でも関数なだけでは、送信中・結果・エラー・楽観表示・入力検証は自動では付いてきません。それを client 側で受け止めるのが、フック三兄弟と conform です。
三兄弟は、SWR / TanStack Query が読み取り側で loading・error・data を代わりに管理してくれるのと同じ発想です。
useActionState ── フォームの基本形
Server Action を呼び、送信中・結果・検証エラーをまとめて持つ。まずこれ1つで足ります。
// [結果, form に渡す関数, 送信中か]
const [state, formAction, isPending] = useActionState(submitContact, null)
<form action={formAction}>
<input name="email" />
<button disabled={isPending}>{isPending ? "送信中…" : "送信"}</button>
</form>
useTransition ── フォームじゃないとき
削除ボタンのように <form> を挟まず Server Action を直接呼ぶ場合の、送信中フラグ。
const [isPending, startTransition] = useTransition()
<button
disabled={isPending}
onClick={() => startTransition(() => deleteItem(id))} // Server Action を直接呼ぶ
>削除</button>
useOptimistic ── 返事を待たずに見せる
いいねのように即時反映したいとき、仮の値を先に描く(詳しくは 3-7)。
// 本物の値(likes)を元に、返事を待たない仮の値を作る
const [shown, addOptimistic] = useOptimistic(likes, (cur, n: number) => cur + n)
<button onClick={() => { addOptimistic(1); toggleLike(id) }}>♥ {shown}</button>
// 失敗しても、本物の likes が降りてきた時点で自動で戻る
conform(+ zod)── 入力検証とエラー表示
1つの zod スキーマを client と server の両方で使い、サーバーが返したエラーを該当の入力欄の下へ戻す。
const [form, fields] = useForm({
lastResult: state, // ← Server Action の戻り値(サーバー側の検証結果)
onValidate: ({ formData }) => parseWithZod(formData, { schema }), // 同じ schema
})
<input name={fields.email.name} />
<p>{fields.email.errors}</p> // 項目ごとのエラーがここに出る
conform は「送信フォーム」に特化した検証ライブラリで、mutation と特に相性がいい。効くのは次の3つ:
- 1つの zod スキーマを client / server で共有。即時検証(体感)と Server Action 内の再検証を、同じルールで二重定義せず書ける。ルールがズレる事故が起きない。
-
サーバー検証エラーを“フィールドに戻す”往復。バックエンドが返す 400/422 を
useForm({ lastResult })に流すと、対応する入力欄の下へ自動表示。この往復が無いと、サーバー側で出たエラーは画面に出ません(=ハンドル漏れ)。 -
a11y 配線の肩代わり。
label↔inputの紐付け・aria-invalid・aria-describedby(入力欄とエラーメッセージの関連付け)を検証状態に合わせて自動生成・同期。手書きだと「エラー時だけ付け忘れる」が起きやすい地味な面倒を肩代わりしてくれる。
狙いは正しさ(検証の一貫性・エラーの見落とし防止・アクセシビリティ)を“型”で底上げすること。見た目の統一はおまけです。
3-3 二重送信を防ぐ
注文や決済などの送信は2回走れば二重注文になります。だから送信系のリクエスト(POST/PUT/PATCH/DELETE)は自動リトライしないのが原則(読み取りと違い、冪等でないため)。UI 側も送信中はボタンを押せなくします。
// useActionState =「Server Action(3-2)を呼ぶ受け皿」
const [state, formAction, isPending] = useActionState(submitAction, null)
<button disabled={isPending}>送信</button> // 送信中は押せない=二重送信を防ぐ
3-4 チェックは2段。ブラウザは体感、サーバーが本番
入力チェックは2回やります。ブラウザ側(conform + zod)はすぐ赤字を出す体感用。でもブラウザの値は改ざんできるので、**サーバー側でもう一度チェックし、そちらを“正”**とします。同じ zod スキーマを両方で使い回すので、二重定義にはなりません。
サーバーが返したエラーは、フォームに戻して各項目の下に表示します。この「戻す」配線を繋がないと、サーバー側のエラーは画面に出ません。
// conform:サーバーの検証結果(state)をフォームへ戻す(往復)
const [form, fields] = useForm({ lastResult: state })
conform とは
フォームの検証・エラー表示・アクセシビリティ配線(ラベル紐付けなど)を肩代わりするライブラリ。Server Action と組み合わせる前提で設計されている。「1つの zod をブラウザとサーバーで共有」「サーバーエラーを項目へ戻す」がいちばんの価値。
3-5 サーバー側の処理は「1本の流れ」に決まっている
Server Action(3-2)の中身は、いつも同じ形に決まっています。
# Server Action の中身
zod で再チェック
└─ 失敗 → その場でフォームへエラーを返す(入力は残す)
送信(保存 or 外部 API へ POST)
├─ 400 / 422 / 409 → サーバーのメッセージを項目へ戻す(入力を保持)
├─ 5xx / 通信失敗 → これも入力を保持してエラー表示(画面は飛ばさない)
└─ 成功 → revalidate(読み側を更新)→ 完了画面へ redirect
入力を守るのが要点
失敗してもthrow してエラーページへ飛ばさない。書きかけの入力が消えるのが一番のストレスだから、値で返してその場に留める。revalidate=送信で古くなった読み側のキャッシュを捨てて最新にすること。
自分の処理を直接呼ぶときも、形は同じ
上の図は外部 API を叩く場合(3-6)。Next だけで完結して関数を直接呼ぶ場合も、この4段の形は変わりません。違うのは真ん中の枝分かれの見分け方だけ。400 や 5xx という番号のかわりに、その関数が返す { ok: false, kind: "conflict" } のような目印で分けます。HTTP を通っていないので、番号を真似る必要はありません。
3-6 どこから API を叩くか(まず「自分の/外部の」を分ける)
順番が大事です。「サーバー経由か、ブラウザ直叩きか」は外部の API を呼ぶときだけの話。その前に「そもそも相手は自分自身なのか、別のサーバーなのか」を分けます。
-
自分の API は「呼ぶ」。Next だけで完結する処理なら、自分の
/api/…を HTTP で叩かず関数を直接呼ぶ。fetchは登場しない。 -
Server Action(既定)。
<form action>に直接渡せて JS 無しでも動く。秘密の鍵をブラウザに出さずに済む。 - 外部 API はサーバー経由。ブラウザから直叩きにしてよいのは「鍵が要らない・CORS 済み・返り値を信頼できる」が全部揃うときだけ。判断軸は「秘密」と「CORS」の2つ。
やりがちな失敗:自分自身に HTTP で話しかける
Server Action や Server Component から fetch("https://自分/api/…") と書いてしまうパターン。同じプロセスの中にいるのに、わざわざネットワークを一周することになります。払うコストは、JSON への詰め直し・自分の URL を組み立てる手間・余計なタイムアウト・そして型が消えること。得られるものはありません(Vercel も直接呼び出しを推奨)。
例外は通信層そのもの(timeout・retry)の挙動を確かめたいときか、RSC 側も MSW でモックしたいときだけ。MSW は通信を横取りする仕組みなので、関数の直接呼び出しには効かないからです。
Route Handler も要るときは「真ん中」を作る
外部から POST も受けたい(=/api/… も残す)場合、業務ロジックを service に切り出して Server Action と Route Handler の両方から呼ぶ。ロジックを2回書かないためです。service には import "server-only" を付けて、ブラウザ側から間違って読み込まれないようにします。
ただし Route Handler 側は「誰からでも叩かれうる入口」なので、そちらでは自前でもう一度 zod チェックする(Server Action の検証を信用できる直接呼び出し側とは、そこだけ扱いが違う)。
なぜ外部はサーバー経由が既定か
別リポジトリのバックエンド(Laravel/Java 等)を呼ぶとき、サーバー側なら秘密鍵を隠せるし、エラーの形をこちらの都合に翻訳できる。ブラウザ直叩きは鍵が露出し、条件が全部揃わない限り選ばない。
3-7 楽観更新は「戻せる形」で
楽観更新=サーバーの返事を待たず、先に画面を更新して速く見せること。
いいね/トグルなど即時反映が要る操作で使い、失敗したら必ず元に戻す(ロールバック前提)。実値がサーバー同期で戻るなら useOptimistic、その場だけの単独トグルは useState。
「誰が戻すか」で必要なものが変わる
useState(自分で戻す) なら、失敗したことを知る必要があるので action は戻り値を返さないといけない(void にしてしまうと戻しようがない)。
useOptimistic(勝手に戻る) なら戻り値は不要。revalidate → 親が再描画される → 本物の値が props で降りてくる、という経路で同期されるので、送信が終わった時点で楽観値がその本物の値に置き換わります。失敗したときはサーバー側が変わっていない=同じ経路で元の値が降りてくるので、何も書かなくてもロールバックになる。
3-8 エラーは4つの面で切り分ける
送信のエラーは種類が違うので、面ごとに分けて扱います(ひとつの try/catch でまとめない)。
| 面 | 扱い |
|---|---|
| (1) ブラウザの入力チェック | conform が送信前に赤字で弾く |
| (2) サーバーの検証(400/422) | 戻り値を useForm({ lastResult }) へ流し各項目へ戻す |
| (3) 通信・サーバー障害(5xx/network) | 入力を保持してエラー表示。監視サービスへ通知 |
| (4) 二重送信・競合 | 送信中は disabled。必要なら重複防止キー |
第3章の要点
読み取り=「同じ結果を何度でも」、書き込み=「一度だけ・戻せる形で」。だから状態も道具もレールが逆。失敗は入力を守り、成功は読み側を更新して完了へ。
設計判断のチェックリスト
Next.js で画面を1つ作るとき、実は手を動かす前に決まっていることがほとんどです。書き終えてから「なぜこうしたか」を思い出すのは大変なので、着手前に次を言葉にしておきます(そのままレビューの観点にもなります)。
データ取得(第2章)
データ1つずつに、① 解決パターン(完全SSR が既定)、② client にしたならその理由を1行で、③ キャッシュ方針。あわせて、取得条件のうち URL に載せるもの/ローカルに留めるものの仕分けと、その zod スキーマ。
書き込み(第3章)
送信ごとに、叩き先(自分/外部)と実行場所(サーバー経由か直叩きか)、2段検証のスキーマ共有、失敗時の入力保持、成功後の revalidate と遷移先。
狙い
「何を作ったか」はコードを読めば分かりますが、「どんな判断をしたか」はコードに残りません。とくに client にした理由(server のままにできなかった事情)と、人間の確認が要る前提(遷移先ページの実在・秘密鍵の要否など)は、コメントかドキュメントに1行でも残しておくと、半年後の自分と次の担当者を助けます。
全体を貫く設計思想
- server が既定、client は理由のある例外。 追加の JS を積まないのが基本。落とすなら理由を1つ言語化する。
- ふるまいは宣言的に。 送信のきっかけ、どこから叩くか。判断を「型」にして、書き手ごとのブレをなくす。
- URL か、一時状態か。 再現したいものは URL、送信直後は一時状態。読み取りと書き込みで“正解の置き場所”が逆。
-
JS 無しでも成立させる。 フォームは素の
<form>を土台に、JS は上乗せ。 - “なぜ” を残す。 コードは「何をしたか」しか語らない。判断の理由はコメントかドキュメントに1行で残す。
機能の早見
既定は Server Component / ブラウザが要る部品だけ "use client" / 待ちがあるところに Suspense / 再現したい状態は searchParams / 書き込みは Server Action / 外部からも叩かれる入口だけ Route Handler。
App Router の機能は、「コードが動く場所が2つに分かれた」という一点から導かれた帰結です。バラバラの便利機能の寄せ集めに見えるうちは、たぶん第1章の地図が入っていない。迷ったらそこへ戻って、「server 既定・宣言的・URL か一時状態か」に立ち返る。