はじめに
美容室、レストラン、クリニックなど、多くのビジネスで予約システムは必須のツールとなっています。この記事では、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-pickerやreact-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を組み合わせて、本格的な予約システムを構築する方法を解説しました。
実装したポイント
- 型安全なデータベース操作: Prismaを使用することで、TypeScriptの型システムを活かした安全なデータベース操作が可能
- 認証機能: NextAuth.jsによるLINE/Google認証の実装
- リアルタイム更新: FirebaseのFirestoreを使った予約情報のリアルタイム同期
- リマインダー通知: Firebase Cloud FunctionsとLINE APIを連携した自動通知
今後の拡張アイデア
- 管理者ダッシュボードの実装
- 複数スタッフの予約スケジュール管理
- 決済機能(Stripe連携)
- キャンセル待ち機能
- 定期予約機能
予約システムは、ユーザー体験を重視しながら、バックエンドの堅牢性も確保する必要があります。Next.jsのServer ComponentsとApp Routerを活用することで、パフォーマンスとSEOを両立した現代的なWebアプリケーションを構築できます。
参考文献
- Next.js 公式ドキュメント - App Router、Server Componentsの詳細
- Prisma 公式ドキュメント - スキーマ定義、マイグレーションのガイド
- Firebase 公式ドキュメント - Firestore、Cloud Functionsの使い方
- NextAuth.js 公式ドキュメント - 認証プロバイダの設定方法
- LINE Developers - LINE Messaging APIのリファレンス