Documentation Next.js

はじめに

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

参考文献

円