オンライン予約サービスは、利用者向けの画面だけでは運営できません。「今日の予約は何件か」「返金に失敗した予約はないか」「料金を改定したい」といった作業のために、運営側の管理画面が必要になります。この回では、予約の一覧を見られて、スペースの名前・説明・収容人数・料金を直せる管理画面を作ります。管理画面は一般の画面より重い権限を持つので、「誰が入れるか」の決め方と守り方が中心の話題です。
「管理画面は社内の人しか使わないから、簡単なものでいいと思っていた。でも、変なところから入られたり、間違えて料金を消したりしないか心配…」
先に結論です。管理画面は「ログインしているか」と「管理者か」を、画面ごと・操作ごとに毎回サーバーで確認するのが基本です。そして、管理者でない人には「権限がありません」ではなく「ページが存在しない(404)」を返すと、管理画面があること自体を知られにくくなります。
前回の第8回では、予約確認メールとキャンセル・返金を作りました。詳しくは次の記事で解説しています。


今回はその続きで、運営が予約と返金の状況を確認し、スペースの情報を直せるようにします。
この回で作るもの:予約一覧とスペース編集の管理画面
作る機能は3つです。使ったバージョンは Next.js 16・Zod 4 で、コードは blogs/it_hacchu/series/next-yoyaku/code/ にあります。
| 機能 | 内容 | 主なファイル |
|---|---|---|
| 管理者の判定 | ログイン中の会員のうち、設定に書いたメールアドレスの人だけを管理者にする | lib/admin.ts |
| 予約の一覧 | 状態(支払い待ち・確定・取消・期限切れ)で絞り込み、返金の状況も見られる | app/admin/page.tsx、lib/admin-reservations.ts |
| スペースの編集 | 名前・説明・収容人数・料金を直す。入力はサーバーで検査する | app/admin/spaces/、lib/admin-spaces.ts、components/space-edit-form.tsx |
今回はデータベースの変更はありません。管理者を「データベースの列」ではなく「環境変数の設定」で決めたためです(理由は次の節で説明します)。
管理者かどうかの決め方:まずは設定で持つ
管理者の持たせ方は、大きく2通りあります。
| 方法 | よいところ | 気をつけること |
|---|---|---|
| 環境変数(設定)に管理者のメールアドレスを書く | 画面から管理者を増やせないので、「最初の管理者を作る入口」という弱点がそもそも無い | 管理者を変えるには設定の変更と再起動が必要 |
| データベースの会員表に「役割」の列を持つ | 管理者を画面から増減できる | 最初の1人をどう作るか、役割の変更を誰ができるかを別途守る必要がある |
運営者が数人で、入れ替わりが少ないうちは、前者で十分です。今回はこちらにしました。管理者が増えて入れ替わりも出てきたら、役割の列と「役割を変える画面」を足します。その場合も、役割を変える操作自体を管理者に限る点は同じです。
// lib/admin.ts(抜粋)
// 管理者のメールアドレスは環境変数 ADMIN_EMAILS(カンマ区切り)で決める
export function parseAdminEmails(value: string | undefined): string[] {
return (value ?? "")
.split(",")
.map((v) => v.trim().toLowerCase())
.filter((v) => v !== "");
}
export function isAdminEmail(email: string, adminEmails: string[] = parseAdminEmails(process.env.ADMIN_EMAILS)): boolean {
return adminEmails.includes(email.trim().toLowerCase());
}
メールアドレスは大文字小文字を区別せず比べます。設定が空のときは、誰も管理者になりません(空の設定で全員が通ってしまう、という事故を防ぐ向きです)。
設定は .env.example に項目だけ載せています。
# .env.example(追記分)
ADMIN_EMAILS=
権限の確認は「画面ごと・操作ごと」に入れる
ここが今回いちばん大切な点です。管理画面の入口で一度だけ確認すれば安全、とは限りません。
📰 出典:Next.js ドキュメント「How to implement authentication in Next.js」
Next.js の公式ドキュメントでは、レイアウト(複数のページで共通の枠)は画面遷移のたびには再実行されないため、権限の確認をレイアウトだけに置かないよう注意しています。また、Server Actions(画面から呼べるサーバー側の関数)は、公開された API の入口と同じ慎重さで扱い、操作の前に「この人に許可されているか」を確認するよう書かれています(執筆時点:2026年10月)。
そこで、確認を1つの関数にまとめ、すべてのページとすべての Server Action の先頭で呼びます。
// lib/admin.ts(抜粋)
// 未ログインはログイン画面へ。ログイン済みでも管理者でなければ 404(管理画面があること自体を見せない)
export async function requireAdmin() {
const user = await getCurrentUser();
if (!user) redirect("/login");
if (!isAdminEmail(user.email)) notFound();
return user;
}
ページ側は先頭の1行です。
// app/admin/page.tsx(抜粋)
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ status?: string }> }) {
await requireAdmin(); // layout ではなくページごとに確認する
const status = parseStatusFilter((await searchParams).status);
const rows = await listAllReservations(status);
// ...
}
「確認を書き忘れた」が一番起きやすい失敗なので、確認は関数1つに集約し、書くのは1行にしています。
予約の一覧:URL の値はそのまま使わない
予約一覧は、状態で絞り込めるようにしました。絞り込みの値は URL の ?status= から届きますが、URL は利用者が自由に書き換えられます。知っている値(4種類)だけを通し、それ以外は「絞り込みなし」として扱います。
// lib/admin-reservations.ts(抜粋)
export const RESERVATION_STATUSES = ["pending", "confirmed", "cancelled", "expired"] as const;
export const MAX_ADMIN_ROWS = 100; // 画面に出す上限。件数が増えたらページ送りを足す(連載では省略)
export function parseStatusFilter(value: string | undefined): ReservationStatus | undefined {
return RESERVATION_STATUSES.find((s) => s === value);
}
export async function listAllReservations(status?: ReservationStatus) {
return getDb()
.select({
id: reservations.id,
startsAt: reservations.startsAt,
// ...(金額・返金の状況・予約者の連絡先・スペース名)
})
.from(reservations)
.innerJoin(spaces, eq(spaces.id, reservations.spaceId))
.where(and(status ? eq(reservations.status, status) : undefined))
.orderBy(desc(reservations.startsAt))
.limit(MAX_ADMIN_ROWS);
}
この一覧には予約者の名前とメールアドレスが含まれます。そのため、この関数は requireAdmin() のあとでしか呼ばない決まりにしています。件数の上限(100件)も付けています。件数が増えたときに画面が重くなるのを防ぐためで、本格的に使うならページ送りと検索を足します。
返金の状況は「返金失敗(要対応)」と表示します。第8回で作った返金は、失敗しても取消を戻さず、記録を残して人が対応する設計でした。その「人が気づく場所」が、この一覧です。
スペースの編集:入力は管理画面でもサーバーで検査する
管理者が入力するのだから間違いは少ない、とは言えません。桁の打ち間違い(2,200円のつもりで220,000円)は、管理者でも起きます。第3回の予約フォームと同じく、Zod でサーバー側の検査を行います。
// lib/admin-spaces.ts(抜粋)
export const MAX_HOURLY_PRICE_YEN = 100_000; // 入力ミス(桁の打ち間違い)を止めるための上限
export const spaceEditSchema = z.object({
id: z.coerce.number().int().positive(),
name: z.string().trim().min(1, "スペース名を入力してください").max(60, "スペース名は60文字以内で入力してください"),
description: z.string().trim().min(1, "説明を入力してください").max(400, "説明は400文字以内で入力してください"),
capacity: z.coerce.number().int("収容人数は整数で入力してください").min(1, "収容人数は1名以上にしてください").max(200, "収容人数は200名以内にしてください"),
hourlyPriceYen: z.coerce
.number()
.int("料金は整数(円)で入力してください")
.min(100, "料金は100円以上にしてください")
.max(MAX_HOURLY_PRICE_YEN, `料金は${MAX_HOURLY_PRICE_YEN.toLocaleString("ja-JP")}円以内にしてください`)
.refine((v) => v % 100 === 0, "料金は100円単位で入力してください"),
});
更新の処理は次のとおりです。
// lib/admin-spaces.ts(抜粋)
// slug(URL)は変えない。変えると、すでに出回っているリンクが切れる。
// 料金を変えても、入っている予約の金額(reservations.total_yen)は変わらない。予約時の金額を予約の側に持っているため
export async function updateSpace(input: SpaceEditInput) {
const { id, ...values } = input;
const rows = await getDb().update(spaces).set(values).where(eq(spaces.id, id)).returning({ id: spaces.id });
return rows.length > 0;
}
ここで効いているのが、第6回で決めた「予約時にサーバーが計算した金額を予約に保存する」設計です。料金を改定しても、すでに支払い済みの予約の金額や、キャンセル時の返金額の計算元は変わりません。予約表が「料金表の最新の値」を毎回見に行く作りだと、料金改定のたびに過去の予約の金額が変わってしまいます。
Server Action は、先頭で管理者を確認してから、検査・更新・画面の更新を行います。
// app/admin/spaces/actions.ts(抜粋)
"use server";
export async function saveSpace(_prev: SpaceEditState, formData: FormData): Promise<SpaceEditState> {
await requireAdmin(); // Server Action は画面を通らずに直接呼べるので、必ず先頭で確認する
const result = validateSpaceEdit(Object.fromEntries(formData));
if (!result.ok) return { errors: result.errors };
const found = await updateSpace(result.input);
if (!found) return { errors: { form: "スペースが見つかりません" } };
revalidatePath("/spaces"); // 利用者向けの一覧・詳細にも反映する
revalidatePath("/admin/spaces");
return { errors: {}, saved: true };
}
revalidatePath は、「この URL の表示用データは古くなったので作り直してください」と Next.js に伝える関数です。これを呼ばないと、管理画面で料金を直しても、利用者向けの画面に古い料金が残ることがあります。
動作確認の方法
ローカルの PostgreSQL 16 で、サンプルの会員を2人作り(管理者と一般会員)、ADMIN_EMAILS に管理者のアドレスだけを入れて確認しました。
| 操作 | 結果 |
|---|---|
未ログインで /admin を開く | ログイン画面へ移動した |
一般会員で /admin・/admin/spaces を開く | どちらも 404 になった |
管理者で /admin・/admin/spaces を開く | 表示された。?status= に不正な値を入れても 200 で全件表示 |
| 管理者がフォームで料金に「1550」を送る | 「料金は100円単位で入力してください」と表示された |
| 管理者が料金を「1600」にして保存 | 「保存しました。」と表示され、利用者向けの一覧にも反映された |
| 一般会員の Cookie で、保存の Server Action を直接呼ぶ | 404 が返り、データベースは変わらなかった |
| ログインしていない状態で同じ呼び出し | ログイン画面への移動が返り、データベースは変わらなかった |


上の画像は開発環境での確認画面です(デザインは未調整で、動作確認用の最小限の表示です)。自動テストは、管理者の判定・状態フィルタ・スペース編集の入力検査・データベースの更新を加えて、全体で107件が成功しました(型チェック・lint・ビルドも成功)。なお、この環境の都合で、ビルドは next build --webpack で確認しています。
つまずきやすい点・セキュリティ上の注意
- 確認をレイアウトだけに置かない。 ページと Server Action の両方で確認します。画面に出さないだけでは、直接の呼び出しは止められません。
- 管理者の操作でも入力を検査する。 画面側の入力欄の制限(HTML の
stepやmin)は親切のためで、守りはサーバーの検査です。 - 管理画面の存在を教えない。 権限のない人には 404 を返しました。ただし、これは補助的な工夫で、本当の守りは「毎回の確認」です。
- 本番では追加の対策が要ります。 管理者のログインに二段階認証を付ける、操作の記録(誰がいつ料金を変えたか)を残す、管理画面へのアクセス元を制限する、などです。連載では省略しています。「これで絶対に安全」ということはありません。
発注者向けメモ:管理画面を頼むときの確認点
管理画面は「あとから足せばいい」と後回しにされがちですが、運営が始まると一番使う画面です。依頼するときは、次を確認してください。
- ☐ 管理者になれる人は誰か、どう決めるか(設定か、画面から増減できるか)が決まっている
- ☐ 管理者の追加・削除の手順が、運用の担当者に引き継がれる
- ☐ 料金や設定を変えたとき、すでに受けた予約の金額が変わらない作りになっている
- ☐ 誰がいつ何を変えたかの記録(操作ログ)を残すか決めている
- ☐ 予約者の個人情報を、管理画面で誰が見られるかが決まっている
- ☐ 返金の失敗など、人が対応すべきものに気づける場所がある
開発会社への質問例です。
- 「管理画面に入れるのは誰で、どうやって決めますか。管理者を増やす手順も教えてください」
- 「管理画面の権限の確認は、画面ごとと操作ごとの両方に入っていますか」
- 「料金を変更したら、すでに入っている予約の金額はどうなりますか」
- 「管理者の操作(料金の変更など)の記録は残せますか。見積もりに含まれていますか」
管理画面は、機能を足すほど工数が増えやすい部分です。予約の検索・CSV出力・売上集計・権限の細分化(受付担当と経理で見られる範囲を変える、など)は、あとから追加になりやすい項目です。最初に「運営で毎日やる作業」を書き出して、優先順位を付けておくと、見積もりのずれを小さくできます。
まとめと次回予告
この回では、管理者を設定で決め、すべてのページと Server Action の先頭で権限を確認する管理画面を作りました。予約の一覧で返金失敗に気づけるようにし、スペースの料金などを、サーバーの検査つきで直せるようにしました。
次回の第10回では、ここまで作った予約の流れをテストで自動的に確かめます。Vitest に加えて、ブラウザを自動操作する Playwright で、予約から確認までの通しの確認に挑戦します。
この連載の記事一覧
この記事は連載「Next.jsで作るオンライン予約サービス」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Next.jsで作るオンライン予約サービス 第0回】完成像とNext.js 16の雛形を動かす(App Router・PostgreSQL・Drizzle)
- 【Next.jsで作るオンライン予約サービス 第1回】スペース一覧と詳細ページを作る(Server Components・Drizzle・メタデータ)
- 【Next.jsで作るオンライン予約サービス 第2回】空き状況カレンダーを作る(時間枠・日本時間・Drizzle)
- 【Next.jsで作るオンライン予約サービス 第3回】予約フォームをServer Actionsで作る(Zodによる入力チェックとエラー表示)
- 【Next.jsで作るオンライン予約サービス 第4回】ダブルブッキングを防ぐ(PostgreSQLの排他制約と同時アクセスのテスト)
- 【Next.jsで作るオンライン予約サービス 第5回】会員ログインを付ける(メールのリンクで入る方式と予約履歴)
- 【Next.jsで作るオンライン予約サービス 第6回】Stripeで支払いを受ける(Checkoutとサーバー側で決める金額)
- 【Next.jsで作るオンライン予約サービス 第7回】Webhookで予約を確定する(署名の検証・二重処理の防止・仮予約の期限切れ)
- 【Next.jsで作るオンライン予約サービス 第8回】予約確認メールとキャンセル・返金(キャンセル規定・Stripeの返金・メール送信)
- 【Next.jsで作るオンライン予約サービス 第9回】管理画面を作る(管理者の権限・予約一覧・スペースの料金編集)(この記事)









コメント
コメント一覧 (1件)
[…] […]