Next.jsでECサイトを作る #5 注文完了ページとデプロイ
前回はチェックアウトフォームとServer Action、決済シミュレーションまでまとめて、カートが注文になる経路を完成させました。最後の記事では注文完了ページでユーザーフローを閉じ、Vercelにデプロイして実際にインターネットに公開し、シリーズ全体と「実サービスに進むにはどこに手を入れるべきか」まで整理してまとめます。
/orders/[orderId] — 注文完了ページ
#
注文直後にクライアントがrouter.push('/orders/${orderId}')で到着するページです。注文IDでメモリストアを照会して内容を表示します。
src/app/orders/[orderId]/page.js。
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getOrder } from '../../lib/orders';
import { formatPrice } from '../../lib/format';
export const metadata = {
title: '注文完了',
description: '注文が正常に受け付けられました。',
robots: { index: false, follow: false },
};
export default async function OrderConfirmationPage({ params }) {
const { orderId } = await params;
const order = getOrder(orderId);
if (!order) notFound();
return (
<main className="mx-auto max-w-2xl px-4 py-8">
<section className="rounded-lg bg-green-50 p-6 text-center">
<h1 className="text-2xl font-bold text-green-800">注文が完了しました</h1>
<p className="mt-2 text-sm text-green-700">
注文番号 <code className="font-mono">{order.id}</code>
</p>
</section>
<section className="mt-6">
<h2 className="text-lg font-semibold">注文内容</h2>
<ul className="mt-2 divide-y border-y text-sm">
{order.items.map((it) => (
<li key={it.slug} className="flex justify-between py-2">
<span>{it.name} × {it.quantity}</span>
<span>{formatPrice(it.price * it.quantity)}</span>
</li>
))}
</ul>
<div className="mt-3 flex justify-between text-lg font-semibold">
<span>合計</span>
<span>{formatPrice(order.total)}</span>
</div>
</section>
<section className="mt-6">
<h2 className="text-lg font-semibold">配送情報</h2>
<dl className="mt-2 grid grid-cols-3 gap-y-1 text-sm">
<dt className="text-gray-500">お届け先</dt>
<dd className="col-span-2">{order.shipping.name}</dd>
<dt className="text-gray-500">電話番号</dt>
<dd className="col-span-2">{order.shipping.phone}</dd>
<dt className="text-gray-500">住所</dt>
<dd className="col-span-2">{order.shipping.address}</dd>
</dl>
</section>
<div className="mt-8 flex gap-3">
<Link
href="/products"
className="rounded-md border px-4 py-2 text-sm"
>
買い物を続ける
</Link>
<Link
href="/"
className="rounded-md bg-black px-4 py-2 text-sm text-white"
>
ホームへ
</Link>
</div>
</main>
);
}設計のディテールは次のとおりです。
robots: { index: false, follow: false }— 注文完了ページは検索エンジンがインデックスする理由がありません。個人情報(名前、電話、住所)が含まれるページが検索に露出する可能性を最初から遮断します。- 不正なIDは
notFound()— 他のユーザーの注文IDを推測して入ってきても、その注文がメモリになければ(サーバー再起動など)404に送ります。 - 次の行動は2方向 — 「買い物を続ける」と「ホームへ」。決済直後のユーザーが行き止まりに閉じ込められないよう、次の動線を明示します。
注文ページのセキュリティについての短いコメント #
今の実装は注文IDさえ知っていれば誰でもその注文を見られる構造です。学習用デモには十分ですが、実サービスなら次の補強が必要です。
- 注文IDを長くて推測不可能な乱数に (現在はtimestamp + 6文字なので一部推測可能)
- 非ログイン決済なら、注文照会用の使い捨てトークンを別途発行 (メールのリンクに添付)
- ログインユーザーなら、本人の注文だけ見られるようにセッションとマッチング
最後のセクションで再び取り上げます。
サイトmetadataの整理 #
最後にmetadataを整えて、SEOの基本を押さえておきます。ブログシリーズの#5と似たパターンですが、ECサイトの特性に合わせて調整します。
src/app/layout.js (metadata部分)。
export const metadata = {
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'),
title: {
default: '私のショップ',
template: '%s | 私のショップ',
},
description: '学習用ECサイトビルド — 良い商品に出会えます。',
openGraph: {
type: 'website',
locale: 'ko_KR',
siteName: '私のショップ',
},
};商品詳細ページのOGもproductタイプに整えると、SNSプレビューがより豊かになります。
src/app/products/[slug]/page.js (generateMetadataの補強)。
export async function generateMetadata({ params }) {
const { slug } = await params;
const product = getProductBySlug(slug);
if (!product) return {};
return {
title: product.name,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
type: 'website',
images: [{ url: product.image }],
},
};
}本格的なOG画像の自動生成(ImageResponse)までは扱いません。静的な画像をOGとして公開するレベルだけ押さえて、より精巧なOGは別のテーマとして残します。
環境変数とデプロイの準備 #
デプロイ環境ではドメインが変わるので、サイトURLを環境変数に分離します。
.env.local。
NEXT_PUBLIC_SITE_URL=http://localhost:3000VercelプロジェクトのEnvironment Variablesには実際のドメインを登録します。
NEXT_PUBLIC_SITE_URL=https://your-shop.vercel.appmetadataBaseがこの値を基準にOG画像の絶対URLを生成します。開発/プレビュー/プロダクションが、それぞれ自分のドメインを自動で持つようになります。
Vercelデプロイ #
ブログシリーズと同じ流れです。
- GitHubリポジトリにコードをpush
- vercel.comで「New Project」 → リポジトリを選択
- Framework Preset: Next.js (自動検出)
- Environment Variablesに
NEXT_PUBLIC_SITE_URLを追加 - Deploy
ビルドが終わるとhttps://your-shop.vercel.appのようなドメインが発行されます。一度入ってカタログを見て回り、カートに入れて、決済まで一度やってみましょう。
デプロイ後に現れるメモリストアの限界 #
デプロイして実際に使ってみると、学習段階では見えなかった問題が2つ現れます。
1. 決済後の注文ページで404 #
チェックアウトまでは成功したのに/orders/[orderId]が404を出すケースが生じます。原因はVercelのサーバーレス関数のインスタンス分離です。
- 決済リクエスト(
checkoutAction)はインスタンスAで処理され、注文がAのメモリMapに保存される - 直後の注文照会(
/orders/[orderId]のServer Component)はインスタンスBで実行される - Bのメモリにはその注文がない →
notFound()
メモリストアは1つのプロセスの中でだけ意味があります。複数のインスタンスにまたがってデータを共有するには、そのデータをどこか外部に置く必要があります。DBが最も標準的な答えです。
2. 再デプロイするとすべての注文が消える #
新しいデプロイが上がると、サーバーレス関数は新しいインスタンスとして起動します。モジュールスコープのMapが再び空の状態から出発します。過去の注文はすべて蒸発します。
学習デモとしては十分なトレードオフですが、「実サービスならどこで行き詰まるはずか」を正確に見せてくれる良い学習ポイントです。
実サービスに進むにはどこに手を入れるべきか #
このシリーズがあえて単純化した領域を、もう一度見てみます。
| 領域 | 学習用の実装 | 実サービスでの推奨 |
|---|---|---|
| 商品ストア | JSONファイル | DB (PostgreSQL、MySQL、SQLite) またはHeadless CMS |
| 注文ストア | モジュールスコープのメモリMap | DB。単一インスタンスの仮定は不可 |
| 価格検証 | クライアントが送った価格を合算 | サーバーが商品マスターから価格を再照会して合算 |
| 在庫管理 | 静的なstockフィールド、減算なし | 決済時にトランザクション内で在庫を減算 + 同時実行の処理 |
| 決済 | シミュレーション (Math.random) | Toss Payments / Stripe / Iamportなどの決済代行(PG)連携 + Webhook |
| 認証 | なし。誰でも決済 | NextAuth.js / Clerk / Luciaなど |
| 注文照会の権限 | 注文IDさえ知っていれば誰でも | ログインユーザーは本人の注文のみ / 非ログインは使い捨てトークン |
| 注文ステータス | paid固定 | pending / paid / preparing / shipped / delivered / canceled のステートマシン |
| メール通知 | なし | 決済/配送の段階ごとにResend / SendGridなどでメール |
| 管理画面 | なし | 注文一覧 / 在庫管理 / 返金処理 |
| セキュリティ | HTML5 required + サーバーガード | + CSRFトークン / レートリミット / 入力サニタイズ |
各項目の1つ1つが、単独のシリーズとして掘り下げられるだけの深さを持っています。しかし、このシリーズで作ったカタログ → カート → チェックアウト → 注文完了というフローの骨格はそのまま生き残ります。上の項目は、その骨格に少しずつ埋めていく肉になります。
シリーズの振り返り — Next.jsでECサイトを作る #
このシリーズで私たちは、空のプロジェクトから始めて、フローが閉じたデモECサイトまで作りました。
| # | 追加された機能 | 登場した中核パターン/ツール |
|---|---|---|
| 1 | 設計、データモデル、セットアップ | App Router、JSONシード、lib/の分離 |
| 2 | カタログ (一覧 / 詳細 / フィルター) | Server Component、searchParams、generateStaticParams、notFound、next/image |
| 3 | カート | Client Context、localStorageでの永続化、hydration安全パターン |
| 4 | チェックアウト + 決済 | Server Action、useActionState、useFormStatus、決済シミュレーション |
| 5 | 注文完了 + デプロイ | metadata API、robots、Vercel、メモリストアの限界 |
ブログシリーズと同じ5編ですが、扱う領域が違います。ブログは読みとコメントが中心でしたが、ECサイトはユーザー状態(カート)からトランザクション(注文)へという流れが中心でした。両方のビルドを追いかけてこられた方は、読み中心のサイトと状態/トランザクション中心のアプリ、両方の実戦パターンが手に取れるようになっているはずです。
次に行けるところ #
これで、自分のプロジェクトを始めるベースがさらに固くなりました。次のステップとして進める方向は次のとおりです。
このECサイトを本物にするなら #
- DBの導入 — Prisma + PostgreSQLまたはDrizzle + SQLiteから。
products.js/orders.jsの呼び出しシグネチャを維持したまま内部だけ入れ替えれば、ページのコードはほとんど触らずに済みます - 実際の決済代行(PG)連携 — Toss Payments(韓国)またはStripe(グローバル)の決済画面の表示とWebhookの受信
- 認証の導入 — NextAuth.jsでメール/ソーシャルログイン、本人の注文照会
- 管理画面 — 注文一覧 / 商品追加 / 在庫管理
また別のビルド #
- ソーシャルアプリ — 掲示板、フォロー、通知 — リアルタイム性と多対多のリレーション
- ダッシュボード — チャート、フィルター、データ可視化 — 外部データソースの連携
- 自分の領域のツール — 普段から使いたかった小さなツール。最も学習効果の大きい選択
おわりに #
ここまで付いてきてくださってありがとうございます。小さなカタログ1ページから出発して、カートと決済、注文完了まで1つのフローにまとめました。学習用の単純化があちこちにありますが、その単純化が行き詰まる地点こそ、実サービスでどんなツールが必要になるかを教えてくれるシグナルです — メモリストアがインスタンス間で分離した瞬間にDBが必要になり、価格をクライアントが送る構造が気になり始めた瞬間にサーバー側の検証強化が必要になります。
Reactのエコシステムは速く変わりますが、このシリーズで扱った「このコードはどこで実行されるのか」というメンタルモデルは変わりません。Server Component / Client Component / Server Actionの分担は、一度手に馴染めば別のツールに乗り換えても同じ直観が働きます。その上にDB、認証、決済代行のようなツールが少しずつ積み重なるだけです。本質を知っていれば、新しいツールが登場してもその位置づけがすぐにわかります。
以上でシリーズを終了します。