Documentation Next.js

はじめに

美容室、レストラン、クリニックなど、多くのビジネスで予約システムは必須のツールとなっています。この記事では、Next.jsを使って本格的な予約システムを構築する方法を解説します。

データベース操作にはPrisma(型安全なORMツール)を、リアルタイム通知にはFirebaseを活用し、さらにLINE APIと連携してリマインダー通知を送信する実践的なシステムを構築します。

この記事を読むことで、以下のことが学べます。

  • Next.jsでの予約システムの基本設計
  • Prismaを使ったデータベース操作
  • Firebaseによるリアルタイム更新と通知
  • LINE APIとの連携によるリマインダー機能

予約システムに必要な機能

予約システムを構築する際に必要となる基本機能を整理します。

1. ユーザー認証

予約システムでは、誰が予約したかを管理する必要があります。NextAuth.jsを使うことで、以下のような認証プロバイダを簡単に実装できます。

  • LINEログイン(日本のユーザー向けに最適)
  • Googleログイン
  • メール/パスワード認証
// app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import LineProvider from "next-auth/providers/line";
import GoogleProvider from "next-auth/providers/google";

const handler = NextAuth({
  providers: [
    // LINEログインの設定
    LineProvider({
      clientId: process.env.LINE_CLIENT_ID!,
      clientSecret: process.env.LINE_CLIENT_SECRET!,
    }),
    // Googleログインの設定
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  callbacks: {
    // セッションにユーザーIDを追加
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.sub!;
      }
      return session;
    },
  },
});

export { handler as GET, handler as POST };

2. 予約カレンダーと日時選択

カレンダーUIは、ユーザーが直感的に予約日時を選択できるようにするために重要です。react-day-pickerreact-calendarなどのライブラリを活用します。

// components/ReservationCalendar.tsx
"use client";

import { useState } from "react";
import { DayPicker } from "react-day-picker";
import "react-day-picker/dist/style.css";

interface ReservationCalendarProps {
  // 予約不可の日付(すでに埋まっている日)
  disabledDates: Date[];
  // 日付選択時のコールバック
  onDateSelect: (date: Date) => void;
}

export function ReservationCalendar({
  disabledDates,
  onDateSelect,
}: ReservationCalendarProps) {
  const [selectedDate, setSelectedDate] = useState<Date | undefined>();

  // 過去の日付と予約不可の日付を無効化
  const disabledDays = [
    { before: new Date() }, // 過去の日付
    ...disabledDates.map((date) => new Date(date)),
  ];

  const handleSelect = (date: Date | undefined) => {
    if (date) {
      setSelectedDate(date);
      onDateSelect(date);
    }
  };

  return (
    <div className="reservation-calendar">
      <DayPicker
        mode="single"
        selected={selectedDate}
        onSelect={handleSelect}
        disabled={disabledDays}
        modifiersStyles={{
          selected: {
            backgroundColor: "#0070f3",
            color: "white",
          },
        }}
      />
    </div>
  );
}

3. 予約管理とデータベース操作

Prismaは、TypeScriptと相性の良い型安全なORM(Object-Relational Mapping)です。データベーススキーマからTypeScriptの型を自動生成してくれるため、開発効率が向上します。

4. リアルタイム更新と通知

予約が作成・変更された際に、リアルタイムで画面を更新したり、ユーザーに通知を送る機能は、現代の予約システムでは必須です。Firebaseの Firestore と Cloud Messaging を活用します。

5. リマインダー機能

予約日の前日や当日に自動でリマインダーを送信する機能により、予約忘れを防止し、キャンセル率を低下させることができます。

技術スタックの概要

今回使用する技術スタックを整理します。

技術用途特徴
Next.js 14フロントエンド/バックエンドApp Router、Server Components対応
PrismaデータベースORM型安全、マイグレーション機能
Firebaseリアルタイム通知Firestore、Cloud Functions
NextAuth.js認証OAuth対応、セッション管理
LINE Messaging APIリマインダー通知プッシュメッセージ、リッチメニュー

実装の流れ

ステップ1: プロジェクトセットアップ

まず、Next.jsプロジェクトを作成し、必要なパッケージをインストールします。

# Next.jsプロジェクトの作成
npx create-next-app@latest reservation-system --typescript --tailwind --app

# プロジェクトディレクトリに移動
cd reservation-system

# 必要なパッケージをインストール
npm install @prisma/client firebase next-auth
npm install -D prisma

# Prismaの初期化
npx prisma init

ステップ2: Prismaでデータベーススキーマを定義

prisma/schema.prismaファイルでデータベースのスキーマを定義します。

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"  // PostgreSQL、MySQL、SQLiteなどに対応
  url      = env("DATABASE_URL")
}

// ユーザーモデル
model User {
  id            String        @id @default(cuid())
  name          String?
  email         String?       @unique
  emailVerified DateTime?
  image         String?
  lineId        String?       @unique  // LINE連携用
  reservations  Reservation[]
  createdAt     DateTime      @default(now())
  updatedAt     DateTime      @updatedAt
}

// 予約モデル
model Reservation {
  id          String            @id @default(cuid())
  userId      String
  user        User              @relation(fields: [userId], references: [id], onDelete: Cascade)
  date        DateTime          // 予約日時
  menuId      String
  menu        Menu              @relation(fields: [menuId], references: [id])
  status      ReservationStatus @default(PENDING)
  note        String?           // 備考・要望
  reminderSent Boolean          @default(false)  // リマインダー送信済みフラグ
  createdAt   DateTime          @default(now())
  updatedAt   DateTime          @updatedAt

  @@index([userId])
  @@index([date])
  @@index([status])
}

// メニューモデル(予約可能なサービス)
model Menu {
  id           String        @id @default(cuid())
  name         String
  description  String?
  duration     Int           // 所要時間(分)
  price        Int           // 価格
  reservations Reservation[]
  createdAt    DateTime      @default(now())
  updatedAt    DateTime      @updatedAt
}

// 予約ステータスの列挙型
enum ReservationStatus {
  PENDING    // 確認待ち
  CONFIRMED  // 確定
  CANCELLED  // キャンセル
  COMPLETED  // 完了
}

スキーマを定義したら、マイグレーションを実行してデータベースに反映します。

# マイグレーションの実行
npx prisma migrate dev --name init

# Prisma Clientの生成
npx prisma generate

ステップ3: Prisma Clientのセットアップ

Prisma Clientをシングルトンとして管理し、開発環境でのホットリロード時に接続が増えすぎないようにします。

// lib/prisma.ts
import { PrismaClient } from "@prisma/client";

// グローバル変数として Prisma Client を保持
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

// 既存のインスタンスがあれば再利用、なければ新規作成
export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === "development"
      ? ["query", "error", "warn"]
      : ["error"],
  });

// 開発環境ではグローバル変数にインスタンスを保持
if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}

ステップ4: 予約APIの実装

Next.js 14のApp Routerを使って、RESTful APIを実装します。

// app/api/reservations/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod";

// 予約作成のバリデーションスキーマ
const createReservationSchema = z.object({
  date: z.string().datetime(),  // ISO 8601形式
  menuId: z.string(),
  note: z.string().optional(),
});

// GET: 予約一覧の取得
export async function GET(request: NextRequest) {
  try {
    const session = await getServerSession();

    if (!session?.user?.id) {
      return NextResponse.json(
        { error: "認証が必要です" },
        { status: 401 }
      );
    }

    // ユーザーの予約を取得
    const reservations = await prisma.reservation.findMany({
      where: {
        userId: session.user.id,
      },
      include: {
        menu: true,  // メニュー情報も含める
      },
      orderBy: {
        date: "asc",  // 日時の昇順
      },
    });

    return NextResponse.json(reservations);
  } catch (error) {
    console.error("予約取得エラー:", error);
    return NextResponse.json(
      { error: "予約の取得に失敗しました" },
      { status: 500 }
    );
  }
}

// POST: 新規予約の作成
export async function POST(request: NextRequest) {
  try {
    const session = await getServerSession();

    if (!session?.user?.id) {
      return NextResponse.json(
        { error: "認証が必要です" },
        { status: 401 }
      );
    }

    const body = await request.json();

    // 入力値のバリデーション
    const validatedData = createReservationSchema.parse(body);

    // 予約日時の重複チェック
    const existingReservation = await prisma.reservation.findFirst({
      where: {
        date: new Date(validatedData.date),
        status: {
          in: ["PENDING", "CONFIRMED"],
        },
      },
    });

    if (existingReservation) {
      return NextResponse.json(
        { error: "この日時はすでに予約が入っています" },
        { status: 409 }
      );
    }

    // 予約を作成
    const reservation = await prisma.reservation.create({
      data: {
        userId: session.user.id,
        date: new Date(validatedData.date),
        menuId: validatedData.menuId,
        note: validatedData.note,
        status: "PENDING",
      },
      include: {
        menu: true,
        user: true,
      },
    });

    return NextResponse.json(reservation, { status: 201 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        { error: "入力値が不正です", details: error.errors },
        { status: 400 }
      );
    }

    console.error("予約作成エラー:", error);
    return NextResponse.json(
      { error: "予約の作成に失敗しました" },
      { status: 500 }
    );
  }
}

ステップ5: 予約キャンセルAPIの実装

// app/api/reservations/[id]/cancel/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { prisma } from "@/lib/prisma";

export async function POST(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const session = await getServerSession();

    if (!session?.user?.id) {
      return NextResponse.json(
        { error: "認証が必要です" },
        { status: 401 }
      );
    }

    // 予約の存在確認と所有者チェック
    const reservation = await prisma.reservation.findUnique({
      where: { id: params.id },
    });

    if (!reservation) {
      return NextResponse.json(
        { error: "予約が見つかりません" },
        { status: 404 }
      );
    }

    if (reservation.userId !== session.user.id) {
      return NextResponse.json(
        { error: "この予約をキャンセルする権限がありません" },
        { status: 403 }
      );
    }

    // キャンセル可能な状態かチェック
    if (reservation.status === "CANCELLED") {
      return NextResponse.json(
        { error: "この予約はすでにキャンセルされています" },
        { status: 400 }
      );
    }

    if (reservation.status === "COMPLETED") {
      return NextResponse.json(
        { error: "完了した予約はキャンセルできません" },
        { status: 400 }
      );
    }

    // 予約をキャンセル
    const updatedReservation = await prisma.reservation.update({
      where: { id: params.id },
      data: { status: "CANCELLED" },
    });

    return NextResponse.json(updatedReservation);
  } catch (error) {
    console.error("予約キャンセルエラー:", error);
    return NextResponse.json(
      { error: "予約のキャンセルに失敗しました" },
      { status: 500 }
    );
  }
}

ステップ6: Firebaseによるリアルタイム通知

Firebaseを使って、予約情報をリアルタイムで同期し、管理者に通知を送信します。

// lib/firebase.ts
import { initializeApp, getApps } from "firebase/app";
import { getFirestore } from "firebase/firestore";
import { getMessaging, getToken } from "firebase/messaging";

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
};

// Firebaseアプリの初期化(重複初期化を防止)
const app = getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];

// Firestoreインスタンス
export const db = getFirestore(app);

// プッシュ通知のトークンを取得
export async function requestNotificationPermission() {
  if (typeof window === "undefined") return null;

  try {
    const messaging = getMessaging(app);
    const permission = await Notification.requestPermission();

    if (permission === "granted") {
      const token = await getToken(messaging, {
        vapidKey: process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY,
      });
      return token;
    }
    return null;
  } catch (error) {
    console.error("通知許可の取得に失敗:", error);
    return null;
  }
}

予約データをFirestoreに保存し、リアルタイムで同期する例です。

// lib/firestore-sync.ts
import {
  collection,
  addDoc,
  onSnapshot,
  query,
  where,
  orderBy,
  serverTimestamp,
  Timestamp
} from "firebase/firestore";
import { db } from "./firebase";

// 予約情報の型定義
interface FirestoreReservation {
  id: string;
  userId: string;
  userName: string;
  date: Timestamp;
  menuName: string;
  status: string;
  createdAt: Timestamp;
}

// 予約をFirestoreに追加
export async function addReservationToFirestore(
  reservation: Omit<FirestoreReservation, "createdAt">
) {
  try {
    const docRef = await addDoc(collection(db, "reservations"), {
      ...reservation,
      createdAt: serverTimestamp(),
    });
    console.log("Firestoreに予約を追加:", docRef.id);
    return docRef.id;
  } catch (error) {
    console.error("Firestore追加エラー:", error);
    throw error;
  }
}

// 予約のリアルタイム監視
export function subscribeToReservations(
  userId: string,
  callback: (reservations: FirestoreReservation[]) => void
) {
  const q = query(
    collection(db, "reservations"),
    where("userId", "==", userId),
    orderBy("date", "asc")
  );

  // onSnapshotでリアルタイム更新を監視
  const unsubscribe = onSnapshot(q, (snapshot) => {
    const reservations: FirestoreReservation[] = [];
    snapshot.forEach((doc) => {
      reservations.push({
        id: doc.id,
        ...doc.data(),
      } as FirestoreReservation);
    });
    callback(reservations);
  });

  // クリーンアップ関数を返す
  return unsubscribe;
}

ステップ7: LINE APIによるリマインダー通知

Firebase Cloud Functionsを使って、予約前日に自動でLINEリマインダーを送信します。

// functions/src/index.ts(Firebase Cloud Functions)
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import axios from "axios";

admin.initializeApp();

const LINE_CHANNEL_ACCESS_TOKEN = functions.config().line.channel_access_token;

// LINE メッセージ送信関数
async function sendLineMessage(lineUserId: string, message: string) {
  try {
    await axios.post(
      "https://api.line.me/v2/bot/message/push",
      {
        to: lineUserId,
        messages: [
          {
            type: "text",
            text: message,
          },
        ],
      },
      {
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${LINE_CHANNEL_ACCESS_TOKEN}`,
        },
      }
    );
    console.log(`LINEメッセージ送信成功: ${lineUserId}`);
  } catch (error) {
    console.error("LINEメッセージ送信エラー:", error);
    throw error;
  }
}

// 毎日朝9時に実行されるリマインダー送信
export const sendReservationReminders = functions.pubsub
  .schedule("0 9 * * *")  // 毎日9:00に実行
  .timeZone("Asia/Tokyo")
  .onRun(async () => {
    const db = admin.firestore();
    const now = new Date();

    // 明日の日付範囲を計算
    const tomorrow = new Date(now);
    tomorrow.setDate(tomorrow.getDate() + 1);
    tomorrow.setHours(0, 0, 0, 0);

    const dayAfterTomorrow = new Date(tomorrow);
    dayAfterTomorrow.setDate(dayAfterTomorrow.getDate() + 1);

    // 明日の予約を取得
    const reservationsSnapshot = await db
      .collection("reservations")
      .where("date", ">=", admin.firestore.Timestamp.fromDate(tomorrow))
      .where("date", "<", admin.firestore.Timestamp.fromDate(dayAfterTomorrow))
      .where("status", "==", "CONFIRMED")
      .where("reminderSent", "==", false)
      .get();

    const batch = db.batch();
    const sendPromises: Promise<void>[] = [];

    reservationsSnapshot.forEach((doc) => {
      const reservation = doc.data();
      const date = reservation.date.toDate();

      // 予約日時をフォーマット
      const formattedDate = date.toLocaleDateString("ja-JP", {
        year: "numeric",
        month: "long",
        day: "numeric",
        hour: "2-digit",
        minute: "2-digit",
      });

      // リマインダーメッセージを作成
      const message = `【予約リマインダー】
明日のご予約をお知らせします。

日時: ${formattedDate}
メニュー: ${reservation.menuName}

ご来店をお待ちしております。
キャンセルや変更がある場合は、お早めにご連絡ください。`;

      // LINE通知を送信
      if (reservation.lineUserId) {
        sendPromises.push(sendLineMessage(reservation.lineUserId, message));
      }

      // リマインダー送信済みフラグを更新
      batch.update(doc.ref, { reminderSent: true });
    });

    // バッチ更新と通知送信を並行実行
    await Promise.all([batch.commit(), ...sendPromises]);

    console.log(`${sendPromises.length}件のリマインダーを送信しました`);
  });

ステップ8: 予約フォームコンポーネント

// components/ReservationForm.tsx
"use client";

import { useState, useTransition } from "react";
import { ReservationCalendar } from "./ReservationCalendar";

interface Menu {
  id: string;
  name: string;
  duration: number;
  price: number;
}

interface ReservationFormProps {
  menus: Menu[];
  disabledDates: Date[];
}

export function ReservationForm({ menus, disabledDates }: ReservationFormProps) {
  const [selectedDate, setSelectedDate] = useState<Date | null>(null);
  const [selectedTime, setSelectedTime] = useState<string>("");
  const [selectedMenuId, setSelectedMenuId] = useState<string>("");
  const [note, setNote] = useState<string>("");
  const [isPending, startTransition] = useTransition();
  const [error, setError] = useState<string>("");
  const [success, setSuccess] = useState<boolean>(false);

  // 予約可能な時間枠(9:00〜18:00、1時間刻み)
  const timeSlots = Array.from({ length: 10 }, (_, i) => {
    const hour = 9 + i;
    return `${hour.toString().padStart(2, "0")}:00`;
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError("");
    setSuccess(false);

    if (!selectedDate || !selectedTime || !selectedMenuId) {
      setError("すべての項目を入力してください");
      return;
    }

    // 日付と時間を組み合わせてISO文字列を作成
    const [hours, minutes] = selectedTime.split(":").map(Number);
    const reservationDate = new Date(selectedDate);
    reservationDate.setHours(hours, minutes, 0, 0);

    startTransition(async () => {
      try {
        const response = await fetch("/api/reservations", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            date: reservationDate.toISOString(),
            menuId: selectedMenuId,
            note: note || undefined,
          }),
        });

        if (!response.ok) {
          const data = await response.json();
          throw new Error(data.error || "予約に失敗しました");
        }

        setSuccess(true);
        // フォームをリセット
        setSelectedDate(null);
        setSelectedTime("");
        setSelectedMenuId("");
        setNote("");
      } catch (err) {
        setError(err instanceof Error ? err.message : "予約に失敗しました");
      }
    });
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-6">
      {/* エラー表示 */}
      {error && (
        <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
          {error}
        </div>
      )}

      {/* 成功メッセージ */}
      {success && (
        <div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
          予約が完了しました。確認メールをお送りしました。
        </div>
      )}

      {/* メニュー選択 */}
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          メニューを選択
        </label>
        <select
          value={selectedMenuId}
          onChange={(e) => setSelectedMenuId(e.target.value)}
          className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
        >
          <option value="">選択してください</option>
          {menus.map((menu) => (
            <option key={menu.id} value={menu.id}>
              {menu.name} ({menu.duration}分) - ¥{menu.price.toLocaleString()}
            </option>
          ))}
        </select>
      </div>

      {/* カレンダー */}
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          日付を選択
        </label>
        <ReservationCalendar
          disabledDates={disabledDates}
          onDateSelect={setSelectedDate}
        />
      </div>

      {/* 時間選択 */}
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          時間を選択
        </label>
        <div className="grid grid-cols-5 gap-2">
          {timeSlots.map((time) => (
            <button
              key={time}
              type="button"
              onClick={() => setSelectedTime(time)}
              className={`px-3 py-2 rounded-lg text-sm ${
                selectedTime === time
                  ? "bg-blue-500 text-white"
                  : "bg-gray-100 hover:bg-gray-200"
              }`}
            >
              {time}
            </button>
          ))}
        </div>
      </div>

      {/* 備考 */}
      <div>
        <label className="block text-sm font-medium text-gray-700 mb-2">
          備考・ご要望(任意)
        </label>
        <textarea
          value={note}
          onChange={(e) => setNote(e.target.value)}
          rows={3}
          className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
          placeholder="何かご要望があればご記入ください"
        />
      </div>

      {/* 送信ボタン */}
      <button
        type="submit"
        disabled={isPending}
        className="w-full bg-blue-500 text-white py-3 px-4 rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
      >
        {isPending ? "予約中..." : "予約を確定する"}
      </button>
    </form>
  );
}

環境変数の設定

.env.localファイルに必要な環境変数を設定します。

# データベース
DATABASE_URL="postgresql://user:password@localhost:5432/reservation_db"

# NextAuth.js
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="your-secret-key"

# LINE認証
LINE_CLIENT_ID="your-line-client-id"
LINE_CLIENT_SECRET="your-line-client-secret"

# Google認証
GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"

# Firebase
NEXT_PUBLIC_FIREBASE_API_KEY="your-api-key"
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN="your-project.firebaseapp.com"
NEXT_PUBLIC_FIREBASE_PROJECT_ID="your-project-id"
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET="your-project.appspot.com"
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID="your-sender-id"
NEXT_PUBLIC_FIREBASE_APP_ID="your-app-id"
NEXT_PUBLIC_FIREBASE_VAPID_KEY="your-vapid-key"

まとめ

この記事では、Next.js、Prisma、Firebaseを組み合わせて、本格的な予約システムを構築する方法を解説しました。

実装したポイント

  1. 型安全なデータベース操作: Prismaを使用することで、TypeScriptの型システムを活かした安全なデータベース操作が可能
  2. 認証機能: NextAuth.jsによるLINE/Google認証の実装
  3. リアルタイム更新: FirebaseのFirestoreを使った予約情報のリアルタイム同期
  4. リマインダー通知: Firebase Cloud FunctionsとLINE APIを連携した自動通知

今後の拡張アイデア

  • 管理者ダッシュボードの実装
  • 複数スタッフの予約スケジュール管理
  • 決済機能(Stripe連携)
  • キャンセル待ち機能
  • 定期予約機能

予約システムは、ユーザー体験を重視しながら、バックエンドの堅牢性も確保する必要があります。Next.jsのServer ComponentsとApp Routerを活用することで、パフォーマンスとSEOを両立した現代的なWebアプリケーションを構築できます。

参考文献

円