Next.jsでショップを作る #1 開始と設計
前回のシリーズでは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項目で表現される形です。
{
"slug": "minimal-tee-black",
"name": "ミニマルTシャツ (ブラック)",
"category": "apparel",
"price": 24000,
"description": "綿100%。無地デザイン。どこにでも合います。",
"image": "/images/products/minimal-tee-black.jpg",
"stock": 42
}各フィールドの意味。
| フィールド | 型 | 説明 |
|---|---|---|
slug | string | URL識別子。/products/[slug]のslug |
name | string | 商品名 |
category | string | カテゴリID (apparel、goods、book など) |
price | number | 金額 (整数、税込) |
description | string | 商品説明 (簡単なテキスト) |
image | string | public/基準の画像パス |
stock | number | 在庫。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.jsondata/とsrc/app/lib/の分離は意図的なものです。data/はコンテンツ (修正頻度が異なる資料)、lib/はそのデータを扱うコードです。ブログシリーズでposts/とsrc/app/lib/posts.jsを分離したのと同じパターンです。
商品シードデータ #
data/products.jsonに商品をいくつか入れておきます。カタログ画面を作る#2で、これを使って画面を埋めていきます。
[
{
"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で描きますが、データアクセス関数を先に準備しておくと流れが途切れません。
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)— 商品詳細ページで使用。なければnullgetAllCategories()— カテゴリチップ / サイドバーで使用
ブログのposts.jsと違う点が1つあります。ブログはファイルを読むfs作業が入っていましたが、ここではJSONをimportします。Next.jsがビルド時にJSONをJavaScriptモジュールに変換してくれるからです。fsを使えば動的な変更 (ファイル追加) に反応しますが、私たちの商品シードはそれほど頻繁に変わらないので、importの方がシンプルです。
注文ストアの最初の輪郭 #
src/app/lib/orders.jsも輪郭だけ作っておきます。実際に中身が入るのは#4です。
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のデフォルト画面が見えるだけです。それでも一度ちゃんと動くかは確認しておきます。
npm run devhttp://localhost:3000でデフォルト画面が見えればOKです。次の記事から本物の画面を作っていきます。
おわりに #
今回の記事ではショップビルドシリーズの土台を整えました。
- 要件を明確に書き出した (一覧 / 詳細 / カート / チェックアウト / 注文完了)
- 技術決定を固めた (App Router / JSON商品 / Contextカート / メモリ注文 / 決済シミュレーション)
- ルートツリーとコンポーネントレイアウトを描いた
- 商品 / 注文データモデルを決めた
- プロジェクトとフォルダ構造、商品シードデータを作った
products.js/orders.jsユーティリティ関数の最初の輪郭をつかんだ
本格的な画面作業は次の記事からです。「Next.jsでショップを作る #2 商品カタログ」では、上で作ったgetAllProducts / getProductBySlugを活用して/productsと/products/[slug]の2ページを作り、カテゴリフィルターをsearchParamsで付けるところまで進みます。