はじめに
Next.js App Routerを使ったECサイト構築では、Server ComponentsとServer Actionsを活用することで、パフォーマンスとセキュリティを両立できます。この記事では、カート機能からStripe決済までの実装を解説します。
プロジェクト構成
src/
├── app/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── products/
│ │ ├── page.tsx
│ │ └── [id]/
│ │ └── page.tsx
│ ├── cart/
│ │ └── page.tsx
│ ├── checkout/
│ │ ├── page.tsx
│ │ └── success/
│ │ └── page.tsx
│ └── api/
│ └── webhooks/
│ └── stripe/
│ └── route.ts
├── actions/
│ ├── cart.ts
│ └── checkout.ts
├── stores/
│ └── cart.ts
├── components/
│ ├── cart/
│ ├── products/
│ └── checkout/
└── lib/
├── stripe.ts
└── db.ts
型定義
// types/product.ts
export interface Product {
id: string;
name: string;
description: string;
price: number;
image: string;
stock: number;
category: string;
}
export interface CartItem {
product: Product;
quantity: number;
}
export interface Order {
id: string;
userId: string;
items: OrderItem[];
total: number;
status: OrderStatus;
stripeSessionId: string;
createdAt: Date;
}
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled';
export interface OrderItem {
productId: string;
name: string;
price: number;
quantity: number;
}
カート状態管理(Zustand)
// stores/cart.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { Product, CartItem } from '@/types/product';
interface CartState {
items: CartItem[];
addItem: (product: Product, quantity?: number) => void;
removeItem: (productId: string) => void;
updateQuantity: (productId: string, quantity: number) => void;
clearCart: () => void;
getTotalItems: () => number;
getTotalPrice: () => number;
}
export const useCartStore = create<CartState>()(
persist(
(set, get) => ({
items: [],
addItem: (product, quantity = 1) => {
set((state) => {
const existingItem = state.items.find(
(item) => item.product.id === product.id
);
if (existingItem) {
return {
items: state.items.map((item) =>
item.product.id === product.id
? { ...item, quantity: item.quantity + quantity }
: item
),
};
}
return {
items: [...state.items, { product, quantity }],
};
});
},
removeItem: (productId) => {
set((state) => ({
items: state.items.filter((item) => item.product.id !== productId),
}));
},
updateQuantity: (productId, quantity) => {
if (quantity <= 0) {
get().removeItem(productId);
return;
}
set((state) => ({
items: state.items.map((item) =>
item.product.id === productId ? { ...item, quantity } : item
),
}));
},
clearCart: () => set({ items: [] }),
getTotalItems: () => {
return get().items.reduce((total, item) => total + item.quantity, 0);
},
getTotalPrice: () => {
return get().items.reduce(
(total, item) => total + item.product.price * item.quantity,
0
);
},
}),
{
name: 'cart-storage',
storage: createJSONStorage(() => localStorage),
}
)
);
カートプロバイダー(ハイドレーション対応)
// components/cart/CartProvider.tsx
'use client';
import { useEffect, useState } from 'react';
export function CartProvider({ children }: { children: React.ReactNode }) {
const [isHydrated, setIsHydrated] = useState(false);
useEffect(() => {
setIsHydrated(true);
}, []);
if (!isHydrated) {
return <>{children}</>;
}
return <>{children}</>;
}
商品一覧ページ(Server Component)
// app/products/page.tsx
import { Suspense } from 'react';
import { getProducts } from '@/lib/db';
import { ProductGrid } from '@/components/products/ProductGrid';
import { ProductGridSkeleton } from '@/components/products/ProductGridSkeleton';
export default async function ProductsPage({
searchParams,
}: {
searchParams: { category?: string; sort?: string };
}) {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">商品一覧</h1>
<Suspense fallback={<ProductGridSkeleton />}>
<ProductList searchParams={searchParams} />
</Suspense>
</div>
);
}
async function ProductList({
searchParams,
}: {
searchParams: { category?: string; sort?: string };
}) {
const products = await getProducts({
category: searchParams.category,
sort: searchParams.sort,
});
return <ProductGrid products={products} />;
}
商品カード(カート追加機能付き)
// components/products/ProductCard.tsx
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { useState } from 'react';
import { useCartStore } from '@/stores/cart';
import { Product } from '@/types/product';
interface Props {
product: Product;
}
export function ProductCard({ product }: Props) {
const [isAdding, setIsAdding] = useState(false);
const addItem = useCartStore((state) => state.addItem);
const handleAddToCart = async () => {
setIsAdding(true);
addItem(product);
// UIフィードバック用の遅延
await new Promise((resolve) => setTimeout(resolve, 500));
setIsAdding(false);
};
const formatPrice = (price: number) => {
return new Intl.NumberFormat('ja-JP', {
style: 'currency',
currency: 'JPY',
}).format(price);
};
return (
<div className="border rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow">
<Link href={`/products/${product.id}`}>
<div className="relative aspect-square">
<Image
src={product.image}
alt={product.name}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</div>
</Link>
<div className="p-4">
<Link href={`/products/${product.id}`}>
<h3 className="font-semibold text-lg mb-2 hover:text-blue-600">
{product.name}
</h3>
</Link>
<p className="text-gray-600 text-sm mb-2 line-clamp-2">
{product.description}
</p>
<div className="flex items-center justify-between mt-4">
<span className="text-xl font-bold text-blue-600">
{formatPrice(product.price)}
</span>
<button
onClick={handleAddToCart}
disabled={isAdding || product.stock === 0}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isAdding ? '追加中...' : product.stock === 0 ? '在庫切れ' : 'カートに追加'}
</button>
</div>
{product.stock <= 5 && product.stock > 0 && (
<p className="text-orange-600 text-sm mt-2">
残り{product.stock}点
</p>
)}
</div>
</div>
);
}
カートページ
// app/cart/page.tsx
import { CartContent } from '@/components/cart/CartContent';
export const metadata = {
title: 'ショッピングカート',
};
export default function CartPage() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">ショッピングカート</h1>
<CartContent />
</div>
);
}
// components/cart/CartContent.tsx
'use client';
import Image from 'next/image';
import Link from 'next/link';
import { useCartStore } from '@/stores/cart';
export function CartContent() {
const { items, updateQuantity, removeItem, getTotalPrice } = useCartStore();
const formatPrice = (price: number) => {
return new Intl.NumberFormat('ja-JP', {
style: 'currency',
currency: 'JPY',
}).format(price);
};
if (items.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-500 mb-4">カートは空です</p>
<Link
href="/products"
className="text-blue-600 hover:underline"
>
商品一覧を見る
</Link>
</div>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-4">
{items.map((item) => (
<div
key={item.product.id}
className="flex gap-4 p-4 border rounded-lg"
>
<div className="relative w-24 h-24 flex-shrink-0">
<Image
src={item.product.image}
alt={item.product.name}
fill
className="object-cover rounded"
/>
</div>
<div className="flex-1">
<h3 className="font-semibold">{item.product.name}</h3>
<p className="text-gray-600">
{formatPrice(item.product.price)}
</p>
<div className="flex items-center gap-2 mt-2">
<button
onClick={() => updateQuantity(item.product.id, item.quantity - 1)}
className="w-8 h-8 border rounded hover:bg-gray-100"
>
-
</button>
<span className="w-12 text-center">{item.quantity}</span>
<button
onClick={() => updateQuantity(item.product.id, item.quantity + 1)}
className="w-8 h-8 border rounded hover:bg-gray-100"
>
+
</button>
</div>
</div>
<div className="text-right">
<p className="font-semibold">
{formatPrice(item.product.price * item.quantity)}
</p>
<button
onClick={() => removeItem(item.product.id)}
className="text-red-600 text-sm hover:underline mt-2"
>
削除
</button>
</div>
</div>
))}
</div>
<div className="lg:col-span-1">
<div className="border rounded-lg p-6 sticky top-4">
<h2 className="text-xl font-semibold mb-4">注文サマリー</h2>
<div className="space-y-2 mb-4">
<div className="flex justify-between">
<span>小計</span>
<span>{formatPrice(getTotalPrice())}</span>
</div>
<div className="flex justify-between">
<span>送料</span>
<span>{getTotalPrice() >= 5000 ? '無料' : formatPrice(500)}</span>
</div>
</div>
<div className="border-t pt-4 mb-6">
<div className="flex justify-between text-lg font-bold">
<span>合計</span>
<span>
{formatPrice(
getTotalPrice() + (getTotalPrice() >= 5000 ? 0 : 500)
)}
</span>
</div>
</div>
<Link
href="/checkout"
className="block w-full py-3 bg-blue-600 text-white text-center rounded hover:bg-blue-700"
>
レジに進む
</Link>
<p className="text-sm text-gray-500 mt-4 text-center">
¥5,000以上のご購入で送料無料
</p>
</div>
</div>
</div>
);
}
Stripe設定
// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
typescript: true,
});
チェックアウトServer Action
// actions/checkout.ts
'use server';
import { redirect } from 'next/navigation';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/db';
import { CartItem } from '@/types/product';
interface CheckoutResult {
success: boolean;
error?: string;
url?: string;
}
export async function createCheckoutSession(
items: CartItem[],
customerEmail?: string
): Promise<CheckoutResult> {
try {
// 在庫確認
for (const item of items) {
const product = await prisma.product.findUnique({
where: { id: item.product.id },
});
if (!product || product.stock < item.quantity) {
return {
success: false,
error: `${item.product.name}の在庫が不足しています`,
};
}
}
// 在庫を仮押さえ(トランザクション)
const order = await prisma.$transaction(async (tx) => {
// 在庫を減らす
for (const item of items) {
await tx.product.update({
where: { id: item.product.id },
data: { stock: { decrement: item.quantity } },
});
}
// 注文を作成(pending状態)
return tx.order.create({
data: {
status: 'pending',
total: items.reduce(
(sum, item) => sum + item.product.price * item.quantity,
0
),
items: {
create: items.map((item) => ({
productId: item.product.id,
name: item.product.name,
price: item.product.price,
quantity: item.quantity,
})),
},
},
});
});
// Stripeセッションを作成
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
mode: 'payment',
customer_email: customerEmail,
line_items: items.map((item) => ({
price_data: {
currency: 'jpy',
product_data: {
name: item.product.name,
images: [item.product.image],
},
unit_amount: item.product.price,
},
quantity: item.quantity,
})),
metadata: {
orderId: order.id,
},
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/cart?cancelled=true`,
expires_at: Math.floor(Date.now() / 1000) + 30 * 60, // 30分後に期限切れ
});
// 注文にセッションIDを保存
await prisma.order.update({
where: { id: order.id },
data: { stripeSessionId: session.id },
});
return {
success: true,
url: session.url!,
};
} catch (error) {
console.error('Checkout error:', error);
return {
success: false,
error: 'チェックアウトに失敗しました',
};
}
}
export async function handleCheckoutRedirect(
items: CartItem[],
email?: string
) {
const result = await createCheckoutSession(items, email);
if (result.success && result.url) {
redirect(result.url);
}
return result;
}
チェックアウトページ
// app/checkout/page.tsx
import { CheckoutForm } from '@/components/checkout/CheckoutForm';
export const metadata = {
title: 'チェックアウト',
};
export default function CheckoutPage() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">チェックアウト</h1>
<CheckoutForm />
</div>
);
}
// components/checkout/CheckoutForm.tsx
'use client';
import { useState, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { useCartStore } from '@/stores/cart';
import { createCheckoutSession } from '@/actions/checkout';
export function CheckoutForm() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [email, setEmail] = useState('');
const [error, setError] = useState<string | null>(null);
const { items, clearCart } = useCartStore();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await createCheckoutSession(items, email);
if (result.success && result.url) {
window.location.href = result.url;
} else {
setError(result.error || 'エラーが発生しました');
}
});
};
if (items.length === 0) {
router.push('/cart');
return null;
}
return (
<form onSubmit={handleSubmit} className="max-w-md mx-auto space-y-6">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">
メールアドレス
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full border rounded px-3 py-2"
placeholder="example@email.com"
/>
</div>
{error && (
<div className="p-4 bg-red-50 text-red-600 rounded">
{error}
</div>
)}
<button
type="submit"
disabled={isPending}
className="w-full py-3 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? '処理中...' : '決済に進む'}
</button>
</form>
);
}
Stripe Webhook
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { headers } from 'next/headers';
import Stripe from 'stripe';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/db';
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = headers().get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error('Webhook signature verification failed');
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const orderId = session.metadata?.orderId;
if (orderId) {
await prisma.order.update({
where: { id: orderId },
data: { status: 'paid' },
});
}
break;
}
case 'checkout.session.expired': {
const session = event.data.object as Stripe.Checkout.Session;
const orderId = session.metadata?.orderId;
if (orderId) {
// 在庫を戻す
const order = await prisma.order.findUnique({
where: { id: orderId },
include: { items: true },
});
if (order) {
await prisma.$transaction(async (tx) => {
for (const item of order.items) {
await tx.product.update({
where: { id: item.productId },
data: { stock: { increment: item.quantity } },
});
}
await tx.order.update({
where: { id: orderId },
data: { status: 'cancelled' },
});
});
}
}
break;
}
}
return NextResponse.json({ received: true });
}
決済完了ページ
// app/checkout/success/page.tsx
import { stripe } from '@/lib/stripe';
import { ClearCartOnSuccess } from '@/components/checkout/ClearCartOnSuccess';
interface Props {
searchParams: { session_id?: string };
}
export default async function SuccessPage({ searchParams }: Props) {
const sessionId = searchParams.session_id;
if (!sessionId) {
return <div>セッションが見つかりません</div>;
}
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['line_items'],
});
return (
<div className="container mx-auto px-4 py-8 text-center">
<ClearCartOnSuccess />
<h1 className="text-3xl font-bold text-green-600 mb-4">
ご注文ありがとうございます!
</h1>
<p className="text-gray-600 mb-8">
注文確認メールを{session.customer_email}に送信しました
</p>
<div className="max-w-md mx-auto border rounded-lg p-6">
<h2 className="font-semibold mb-4">注文内容</h2>
{session.line_items?.data.map((item) => (
<div key={item.id} className="flex justify-between py-2 border-b">
<span>{item.description}</span>
<span>×{item.quantity}</span>
</div>
))}
<div className="flex justify-between pt-4 font-bold">
<span>合計</span>
<span>¥{session.amount_total?.toLocaleString()}</span>
</div>
</div>
</div>
);
}
まとめ
| 機能 | 実装方法 |
|---|---|
| カート状態 | Zustand + localStorage |
| 商品一覧 | Server Components |
| チェックアウト | Server Actions |
| 決済 | Stripe Checkout |
| 支払い確認 | Stripe Webhooks |