Next.jsでECサイトを作る #5 注文完了ページとデプロイ

前回はチェックアウトフォームとServer Action、決済シミュレーションまでまとめて、カートが注文になる経路を完成させました。最後の記事では注文完了ページでユーザーフローを閉じ、Vercelにデプロイして実際にインターネットに公開し、シリーズ全体と「実サービスに進むにはどこに手を入れるべきか」まで整理してまとめます。

/orders/[orderId] — 注文完了ページ #

注文直後にクライアントがrouter.push('/orders/${orderId}')で到着するページです。注文IDでメモリストアを照会して内容を表示します。

src/app/orders/[orderId]/page.js

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部分)。

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の補強)。

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

.env.local
NEXT_PUBLIC_SITE_URL=http://localhost:3000

VercelプロジェクトのEnvironment Variablesには実際のドメインを登録します。

Vercel 環境変数
NEXT_PUBLIC_SITE_URL=https://your-shop.vercel.app

metadataBaseがこの値を基準にOG画像の絶対URLを生成します。開発/プレビュー/プロダクションが、それぞれ自分のドメインを自動で持つようになります。

Vercelデプロイ #

ブログシリーズと同じ流れです。

  1. GitHubリポジトリにコードをpush
  2. vercel.comで「New Project」 → リポジトリを選択
  3. Framework Preset: Next.js (自動検出)
  4. Environment VariablesにNEXT_PUBLIC_SITE_URLを追加
  5. 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
注文ストアモジュールスコープのメモリMapDB。単一インスタンスの仮定は不可
価格検証クライアントが送った価格を合算サーバーが商品マスターから価格を再照会して合算
在庫管理静的な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、searchParamsgenerateStaticParamsnotFoundnext/image
3カートClient Context、localStorageでの永続化、hydration安全パターン
4チェックアウト + 決済Server Action、useActionStateuseFormStatus、決済シミュレーション
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、認証、決済代行のようなツールが少しずつ積み重なるだけです。本質を知っていれば、新しいツールが登場してもその位置づけがすぐにわかります。

以上でシリーズを終了します。

X