Next.jsでショップを作る #1 開始と設計

読了 10分

前回のシリーズではNext.jsでブログを作りました。その最終回で次の挑戦としてショップをおすすめしましたが、今回のシリーズがまさにそれです。同じApp Routerの上から出発しますが、扱う問題の質が違います。ブログは読むことが中心でしたが、ショップはユーザー状態(カート)、トランザクション(注文作成)、結果の永続化(注文照会)まで扱う必要があります。Server ComponentsとServer Actionsの分担が最も自然に表れる題材です。

5編に分けて段階的に積み上げていきます。

  • #1 開始と設計 ← 今回の記事
  • #2 商品カタログ (一覧 / 詳細 / フィルター)
  • #3 カート (Client Context + localStorage)
  • #4 チェックアウトと決済シミュレーション
  • #5 注文完了ページとデプロイ

要件定義 #

コードを書く前に、私たちが作るショップが何をできるべきかを書き出しておきます。

コア機能

  • 商品一覧ページ (全体 / カテゴリフィルター)
  • 商品詳細ページ (画像、説明、価格、カートに入れる)
  • カートページ (数量変更、項目削除、合計表示)
  • チェックアウトページ (配送情報の入力、決済の進行)
  • 注文完了ページ (注文番号、注文内容の確認)

非機能要件

  • リロードしてもカートを維持 (localStorage)
  • 決済失敗時にユーザーへ知らせ、再試行できる
  • モバイルでも使えるレイアウト
  • 検索エンジンフレンドリーなURL構造

技術決定 (前もって決めておけば揺るがない) #

ブログシリーズでやったように、大きな決定を先に固めておきます。毎回の記事で「これはどうする?」と聞き直さなくて済むようにするためです。

  • フレームワーク: Next.js App Router (Server Components / Server Actions)
  • 商品データ: JSONファイル (DBなし。学習を単純化)
  • カート: Client Context + localStorage (サーバーセッションなし)
  • 注文ストア: モジュールスコープのメモリMap (サーバー再起動で初期化。実サービスならDBが必要だが学習を単純化)
  • 決済: シミュレーション (外部の決済サービス連携なしで、ダミーの結果を80%成功 / 20%失敗に分岐)
  • デプロイ: Vercel

各決定の理由を一行ずつ。

決定理由
App Routerブログシリーズと同じパラダイムを維持。RSC + Actionsが最も輝く領域
JSON商品データDBセットアップの負担を学習から切り離し、ルーティング・レンダリング・フォームロジックに集中
Context + localStorage非ログインショップの標準パターン。サーバーセッションの導入は認証の領域まで引き込んでしまう
メモリ注文ストア「注文が作られ、結果ページで見える」という流れをシンプルなコードで見せる
決済シミュレーション外部決済サービスの登録・キー発行・Webhookセットアップなしで決済フローの分岐を学習

実サービスではどこにどんなツールを差し込むべきかを、最終回で押さえておきます。

コンポーネントとルートの設計 #

ルートツリーを先に描いてみると、フォルダ構造が自然に決まります。

ルートツリー
/                        ← ホーム (ベストセラー / カテゴリの入口)
/products                ← 商品一覧 (?category=... でフィルター)
/products/[slug]         ← 商品詳細
/cart                    ← カート
/checkout                ← 配送情報 + 決済
/orders/[orderId]        ← 注文完了 / 照会

/products?category=apparelのように、カテゴリはsearchParamsで表現します。動的フォルダ[category]ではなくクエリ文字列を使う理由は、カテゴリが「検索フィルターの一種」に近く、価格帯やソートのような他のフィルターが追加されたときに自然に拡張できるからです。ブログシリーズ#3でタグを/tags/[tag]動的フォルダにしたのとは対照的です — あちらは「タグそのものがページ」でしたが、ショップのカテゴリは「商品一覧を絞り込む条件」です。

コンポーネントツリーはページごとに違いますが、大きな絵は次のとおりです。

共通レイアウト
RootLayout
└── CartProvider             — カート状態 (Client Context)
    ├── Header               — ロゴ / カテゴリリンク / カートアイコン (数量バッジ)
    ├── {children}           — 各ページ
    └── Footer

カート状態はほぼすべてのページのHeaderで参照するので (アイコンのバッジ)、RootLayoutのすぐ下にProviderを置くのが自然です。

商品データモデル #

各商品が持つ情報を決めておきます。JSONファイルの1項目で表現される形です。

data/products.json の1項目例
{
  "slug": "minimal-tee-black",
  "name": "ミニマルTシャツ (ブラック)",
  "category": "apparel",
  "price": 24000,
  "description": "綿100%。無地デザイン。どこにでも合います。",
  "image": "/images/products/minimal-tee-black.jpg",
  "stock": 42
}

各フィールドの意味。

フィールド説明
slugstringURL識別子。/products/[slug]slug
namestring商品名
categorystringカテゴリID (apparelgoodsbook など)
pricenumber金額 (整数、税込)
descriptionstring商品説明 (簡単なテキスト)
imagestringpublic/基準の画像パス
stocknumber在庫。0 なら品切れ表示

ブログのfrontmatterと同じく、データの形を先に固めることがコード作成をずっと楽にしてくれます。

注文データモデル #

注文は決済が完了した瞬間に作られます。形は次のとおりです。

注文オブジェクトの形
{
  id: 'ord_1748124000_a8c2',
  createdAt: '2026-05-25T10:00:00.000Z',
  items: [
    { slug: 'minimal-tee-black', name: 'ミニマルTシャツ (ブラック)', price: 24000, quantity: 2 },
    { slug: 'mug-white',         name: 'マグカップ (ホワイト)',      price: 12000, quantity: 1 },
  ],
  total: 60000,
  shipping: {
    name: '山田太郎',
    phone: '090-1234-5678',
    address: '東京都どこか123',
  },
  status: 'paid',
}

idは決済直後にサーバーで発行します。statusはひとまずpaidの1つだけ扱いますが、実サービスならpending / failed / shippedのようなステータスが追加されます。

プロジェクトの生成 #

これで本物のコードに入ります。

プロジェクト生成
npx create-next-app@latest my-shop
cd my-shop

オプションはApp Router、JavaScript、srcディレクトリ、Tailwind使用で進めます。ブログシリーズと同じオプションで、Tailwindはカタログ画面を素早く整えるのに便利です。

フォルダ構造 #

最初から大きな絵でフォルダを決めておくと、記事ごとにあちこち動き回ることが減ります。

プロジェクト構造
my-shop/
├── data/
│   └── products.json            ← 商品シードデータ
├── public/
│   └── images/
│       └── products/            ← 商品画像
├── src/
│   └── app/
│       ├── layout.js            ← RootLayout + CartProvider
│       ├── page.js              ← '/' ホーム
│       ├── products/
│       │   ├── page.js          ← '/products' 一覧
│       │   └── [slug]/
│       │       └── page.js      ← '/products/[slug]' 詳細
│       ├── cart/
│       │   └── page.js          ← '/cart' カート
│       ├── checkout/
│       │   └── page.js          ← '/checkout' チェックアウト
│       ├── orders/
│       │   └── [orderId]/
│       │       └── page.js      ← '/orders/[orderId]' 注文完了
│       └── lib/
│           ├── products.js      ← 商品照会ユーティリティ
│           └── orders.js        ← 注文保存/照会ユーティリティ
└── package.json

data/src/app/lib/の分離は意図的なものです。data/コンテンツ (修正頻度が異なる資料)、lib/そのデータを扱うコードです。ブログシリーズでposts/src/app/lib/posts.jsを分離したのと同じパターンです。

商品シードデータ #

data/products.jsonに商品をいくつか入れておきます。カタログ画面を作る#2で、これを使って画面を埋めていきます。

data/products.json
[
  {
    "slug": "minimal-tee-black",
    "name": "ミニマルTシャツ (ブラック)",
    "category": "apparel",
    "price": 24000,
    "description": "綿100%。無地デザイン。どこにでも合います。",
    "image": "/images/products/minimal-tee-black.jpg",
    "stock": 42
  },
  {
    "slug": "minimal-tee-white",
    "name": "ミニマルTシャツ (ホワイト)",
    "category": "apparel",
    "price": 24000,
    "description": "ブラックと同じ生地のホワイトカラー。",
    "image": "/images/products/minimal-tee-white.jpg",
    "stock": 38
  },
  {
    "slug": "mug-white",
    "name": "マグカップ (ホワイト)",
    "category": "goods",
    "price": 12000,
    "description": "厚手の陶器マグ。食洗機対応。",
    "image": "/images/products/mug-white.jpg",
    "stock": 80
  },
  {
    "slug": "tote-canvas",
    "name": "キャンバストートバッグ",
    "category": "goods",
    "price": 18000,
    "description": "デイリー使いのキャンバストート。A4が余裕で入ります。",
    "image": "/images/products/tote-canvas.jpg",
    "stock": 25
  },
  {
    "slug": "react-book",
    "name": "React実戦ガイド",
    "category": "book",
    "price": 32000,
    "description": "ReactとNext.jsの実戦パターンを一冊にまとめたガイド。",
    "image": "/images/products/react-book.jpg",
    "stock": 0
  },
  {
    "slug": "ts-book",
    "name": "TypeScript入門書",
    "category": "book",
    "price": 28000,
    "description": "TypeScriptの基礎からジェネリクスまで。",
    "image": "/images/products/ts-book.jpg",
    "stock": 15
  }
]

react-bookはわざとstock: 0にしてあります。品切れ表示がカタログでどう動くかを見るための、意図的なケースです。

画像ファイルがまだなくても大丈夫です。public/images/products/フォルダだけ作っておき、自分のプロジェクトなら適当な画像を入れてください。学習目的ならUnsplashのようなところからダウンロードして、適当な名前で保存すれば十分です。画像がなければ、#2で作るnext/imageがplaceholderを表示します。

商品照会ユーティリティの最初の輪郭 #

src/app/lib/products.jsに、商品を読み込んで照会する関数の最初の輪郭を作っておきます。実際の画面は#2で描きますが、データアクセス関数を先に準備しておくと流れが途切れません。

src/app/lib/products.js
import products from '../../../data/products.json';

export function getAllProducts() {
  return products;
}

export function getProductsByCategory(category) {
  if (!category) return products;
  return products.filter((p) => p.category === category);
}

export function getProductBySlug(slug) {
  return products.find((p) => p.slug === slug) ?? null;
}

export function getAllCategories() {
  return Array.from(new Set(products.map((p) => p.category)));
}

各関数の役割。

  • getAllProducts() — 全商品の配列
  • getProductsByCategory(category) — カテゴリでフィルター。引数がなければ全体
  • getProductBySlug(slug) — 商品詳細ページで使用。なければnull
  • getAllCategories() — カテゴリチップ / サイドバーで使用

ブログのposts.jsと違う点が1つあります。ブログはファイルを読むfs作業が入っていましたが、ここではJSONをimportします。Next.jsがビルド時にJSONをJavaScriptモジュールに変換してくれるからです。fsを使えば動的な変更 (ファイル追加) に反応しますが、私たちの商品シードはそれほど頻繁に変わらないので、importの方がシンプルです。

注文ストアの最初の輪郭 #

src/app/lib/orders.jsも輪郭だけ作っておきます。実際に中身が入るのは#4です。

src/app/lib/orders.js
const orders = new Map();

export function saveOrder(order) {
  orders.set(order.id, order);
}

export function getOrder(orderId) {
  return orders.get(orderId) ?? null;
}

モジュールスコープのMapが1つだけです。サーバーが生きている間だけ維持され、再起動すると消えます。学習には十分ですが、実サービスならDBが必要です。最終回でこれをどこへ移すべきかを押さえておきます。

動作確認 #

今の段階ではページに手を付けていないので、devサーバーを立ち上げてもNext.jsのデフォルト画面が見えるだけです。それでも一度ちゃんと動くかは確認しておきます。

dev サーバー
npm run dev

http://localhost:3000でデフォルト画面が見えればOKです。次の記事から本物の画面を作っていきます。

おわりに #

今回の記事ではショップビルドシリーズの土台を整えました。

  • 要件を明確に書き出した (一覧 / 詳細 / カート / チェックアウト / 注文完了)
  • 技術決定を固めた (App Router / JSON商品 / Contextカート / メモリ注文 / 決済シミュレーション)
  • ルートツリーとコンポーネントレイアウトを描いた
  • 商品 / 注文データモデルを決めた
  • プロジェクトとフォルダ構造、商品シードデータを作った
  • products.js / orders.jsユーティリティ関数の最初の輪郭をつかんだ

本格的な画面作業は次の記事からです。「Next.jsでショップを作る #2 商品カタログ」では、上で作ったgetAllProducts / getProductBySlugを活用して/products/products/[slug]の2ページを作り、カテゴリフィルターをsearchParamsで付けるところまで進みます。

X