MENU

問い合わせ


    【Next.jsで作るオンライン予約サービス 第3回】予約フォームをServer Actionsで作る(Zodによる入力チェックとエラー表示)

    オンライン予約サービスの心臓部は、利用者が時間を選んで名前とメールアドレスを送る「予約フォーム」です。前回の空き状況カレンダー(第2回)で「いつ空いているか」を表示できるようになったので、今回はその空き枠から予約を送信できるようにします。

    「予約フォームを作るとき、API を別に用意しないといけないの?入力ミスや改ざんはどう防ぐの?」

    先に結論です。Next.js 16 の Server Actions を使えば、フォームから呼ぶサーバー側の処理を 1 つの関数として書けます。API を別に設計する必要はありません。ただし Server Actions は「誰でも直接呼べる公開の入口」なので、入力チェックは画面ではなくサーバー側で必ず行うのが鉄則です。この回では Zod による「形」の検査と、「予約として成り立つか」の検査を分けて実装します。

    この記事のコードは、連載のサンプルプロジェクト(blogs/it_hacchu/series/next-yoyaku/code/)から抜粋しています。執筆時点(2026年10月)で、lint・型チェック・テスト 28 件・ビルドが通り、ローカルの PostgreSQL 16 で予約の送信・エラー表示・重複予約の拒否を実際に確認しました。なお Docker は動かせなかったため、docker compose での起動は未確認です。

    目次

    この回で作るもの

    • カレンダーの「空き」枠から、予約フォームへ進むリンク
    • 利用時間・人数・お名前・メールアドレスを送る予約フォーム
    • 入力が不正なときに、項目ごとにエラーを表示する仕組み
    • 予約を DB に保存し、完了画面へ移動する処理

    なお、同じ時間に 2 人が同時に予約したときの二重予約防止は第4回、支払いは第6回の担当です。この回の予約は「仮の受付」だと割り切って作ります。

    Server Actions とは:フォームから呼べるサーバー側の関数

    Server Actions は、サーバー上で動く関数を、フォームの送信先として直接指定できる Next.js(React)の仕組みです。従来のように「フォーム → API の URL → サーバー処理」と 3 つに分けて作らなくても、1 つの関数にまとめられます。

    📰 出典:Next.js Docs「Forms」

    観点従来(API を別に作る)Server Actions
    送信先API の URL を設計して呼ぶ関数をそのままフォームに渡す
    入力の受け取りJSON を自分で組み立てるFormData(フォームの値)がそのまま届く
    画面との連携結果を受け取って自前で表示を更新useActionState で結果を画面に反映

    便利な反面、気をつけたい点があります。Server Actions は内部的には外部から呼べる HTTP の入口になっているため、ログインの確認や入力の検査を省くと、画面を経由せず直接送られた不正な値がそのまま処理されてしまいます。Next.js の公式ドキュメントも、Server Actions を公開された API と同じ扱いで守るよう案内しています。

    📰 出典:Next.js Docs「Data Security」

    実装1:空き枠から予約フォームへつなぐ

    まず、前回のカレンダーの「空き」の枠を、予約フォームへのリンクにします。日付と開始時刻は URL に載せます。

    // components/day-calendar.tsx(抜粋)
    {slot.status === "available" ? (
      <Link href={`/spaces/${slug}/reserve?date=${date}&hour=${Number(slot.label.slice(0, 2))}`}>
        {slot.label}〜 {STATUS_TEXT[slot.status]}(予約する)
      </Link>
    ) : (
      <>{slot.label}〜 {STATUS_TEXT[slot.status]}</>
    )}

    予約済み・受付終了の枠はリンクにしません。ただし、リンクにしないことは「予約できない」ことの保証にはなりません。URL は利用者が自由に書き換えられるからです。この点が、次の実装の出発点です。

    実装2:Zod で「入力の形」を検査する

    フォームから届く値は、数値でもすべて文字列です。しかも利用者は、画面の制限を無視した値を送れます。そこで、入力の「形」を Zod(TypeScript 向けの入力検査ライブラリ)で一か所にまとめて定義します。

    📰 出典:Zod 公式サイト

    // lib/booking.ts(抜粋)
    export const MAX_HOURS = 4; // 1回の予約は最大4時間まで
    
    export const bookingSchema = z.object({
      date: z.string().refine((v) => parseDateParam(v) !== undefined, "日付の形式が正しくありません"),
      startHour: z.coerce.number().int("開始時刻が正しくありません").min(OPEN_HOUR).max(CLOSE_HOUR - 1),
      hours: z.coerce.number().int("利用時間が正しくありません").min(1, "1時間以上を選んでください").max(MAX_HOURS, `利用は最大${MAX_HOURS}時間までです`),
      attendees: z.coerce.number().int("人数は整数で入力してください").min(1, "人数は1名以上で入力してください"),
      contactName: z.string().trim().min(1, "お名前を入力してください").max(50, "お名前は50文字以内で入力してください"),
      contactEmail: z.string().trim().max(254).pipe(z.email("メールアドレスの形式が正しくありません")),
    });

    ポイントは 3 つです。

    • z.coerce.number():文字列で届いた “3” を数値の 3 に変換してから検査します。
    • .trim():前後の空白(全角スペースを含む)を取り除いてから検査するので、スペースだけの名前を弾けます。
    • エラーメッセージを日本語で書いておく:そのまま画面に出せます。

    実装3:「予約として成り立つか」を別に検査する

    形が正しくても、予約として成り立つとは限りません。「定員を超えている」「営業時間(21:00)を過ぎる」「過去の時間」「すでに予約済みの時間と重なる」といった検査は、形の検査とは別にまとめます。

    // lib/booking.ts(抜粋)
    export function validateBooking(raw, space, booked, now = new Date()): BookingResult {
      const parsed = bookingSchema.safeParse(raw);
      if (!parsed.success) {
        const errors: FieldErrors = {};
        for (const issue of parsed.error.issues) {
          const key = issue.path[0] as keyof BookingInput | undefined;
          if (key && !errors[key]) errors[key] = issue.message; // 項目ごとに最初のメッセージだけ見せる
        }
        return { ok: false, errors };
      }
      const b = parsed.data;
      const errors: FieldErrors = {};
    
      if (b.attendees > space.capacity) errors.attendees = `このスペースの定員は${space.capacity}名です`;
      if (b.startHour + b.hours > CLOSE_HOUR) errors.hours = `利用は${CLOSE_HOUR}:00までです。時間を短くしてください`;
      // …日付の範囲・過去の時間・予約済みとの重なりの検査(省略)
    
      if (Object.keys(errors).length > 0) return { ok: false, errors };
      // 金額は画面から受け取らず、サーバー側で料金表から計算する
      return { ok: true, booking: b, range, totalYen: space.hourlyPriceYen * b.hours };
    }

    この関数は DB にも Next.js にも依存しない「純粋な関数」にしてあります。そのため、tests/booking.test.ts のようにすばやくテストできます(今回追加した 6 件を含め、全体で 28 件が通過)。

    大事なのは最後の 1 行です。金額は画面から受け取らず、サーバーで計算します。画面に表示した金額を送り返す作りだと、利用者が値を書き換えて安く予約できてしまうためです。テストにも「金額が送られてきても無視する」ケースを入れました。

    なお、今回の作りでは形の検査にひとつでも引っかかると、定員などの検査は次の送信まで行われません。たとえば名前の欠けと人数の超過が同時にあると、まず名前のエラーだけが出ます。利用者にとっては少し手間ですが、コードの単純さを優先しました。気になる場合は、すべてを 1 回で返す形に拡張できます。

    実装4:Server Action でフォームを受け取る

    検査の関数を、Server Action から呼びます。

    // app/spaces/[slug]/reserve/actions.ts(抜粋)
    "use server";
    
    export async function createReservation(slug: string, _prev: ReserveState, formData: FormData): Promise<ReserveState> {
      const raw = Object.fromEntries(formData) as Record<string, FormDataEntryValue>;
      const values = Object.fromEntries(Object.entries(raw).filter(([, v]) => typeof v === "string")) as Record<string, string>;
    
      const space = await findSpaceBySlug(slug);
      if (!space) return { errors: { form: "スペースが見つかりません" }, values };
    
      const dateOk = typeof raw.date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(raw.date);
      const booked = dateOk ? await listReservedRanges(space.id, raw.date as string) : [];
      const result = validateBooking(raw, space, booked);
      if (!result.ok) return { errors: result.errors, values };
    
      const row = await insertReservation(space.id, result.booking, result.range);
      redirect(`/spaces/${space.slug}/reserve/done?id=${row.id}`);
    }

    エラーのときは、エラー内容と入力した値の両方を返します。画面側で入力欄に値を残せるので、利用者は最初から打ち直さずに済みます。成功したときは redirect で完了画面へ移動します。

    予約の保存先は、第2回で作った reservations の表に、お名前・メールアドレス・人数の列を追加して使います(drizzle/0002_*.sql。第2回までのサンプル予約には予約者情報が無いため、これらの列は空欄を許しています)。

    実装5:エラーを表示するフォーム(useActionState)

    フォーム側は、結果を受け取って画面に反映する useActionState を使います。

    📰 出典:React 公式ドキュメント「useActionState」

    // components/reservation-form.tsx(抜粋)
    "use client";
    
    const [state, formAction, pending] = useActionState(createReservation.bind(null, slug), initial);
    const { errors, values } = state;
    
    <form action={formAction} noValidate>
      {errors.form && <p role="alert">{errors.form}</p>}
      <input type="hidden" name="date" value={date} />
      <input type="hidden" name="startHour" value={startHour} />
      …
      <label htmlFor="contactName">お名前</label>
      <input id="contactName" name="contactName" defaultValue={values.contactName ?? ""} aria-invalid={Boolean(errors.contactName)} />
      {errors.contactName && <span role="alert"> {errors.contactName}</span>}
      …
      <button type="submit" disabled={pending}>{pending ? "送信中…" : "この内容で予約する"}</button>
    </form>
    • pending が true の間はボタンを押せなくして、連打による二重送信を和らげます(ただし完全な防止ではありません。この点は第4回で DB 側の制約で守ります)。
    • role="alert" と aria-invalid を付けて、読み上げソフトを使う方にもエラーが伝わるようにしています。
    • noValidate はブラウザ標準のエラー表示を止める指定です。エラー文をサーバーの検査結果に統一するために付けています。

    動作確認:ブラウザで送信してみる

    ローカルの PostgreSQL 16 で npm run db:migrate・npm run db:seed を実行し、npm run build のあと npm start で起動して、ブラウザ(Playwright の Chromium)から実際に操作して確かめました。

    1. カレンダーの「15:00〜 空き」から予約フォームへ移動できた
    2. 名前を空欄・メールアドレスを abc にして送信すると、該当の項目にだけエラーが出て、入力済みの人数は残った
    3. 正しく入力して 2 時間で送信すると予約が保存され、完了画面に「2時間・¥4,400」と表示された(料金は 2,200 円×2 時間をサーバーで計算)
    4. 同じ時間帯に重なる予約を送ると、「選んだ時間にすでに予約が入っています」と断られた
    開発環境での確認画面:名前とメールアドレスの入力エラーが項目ごとに表示されている

    ※ 上の画像は Linux の開発環境での確認画面です。実機のブラウザでは見た目が異なる場合があります。デザインはこの連載の主題ではないため、最小限の見た目にしています。

    つまずきやすい点・セキュリティ上の注意

    • 画面の検査だけで済ませない:type="email" や max 属性などブラウザ側の検査は、親切のためのもので、防御にはなりません。必ずサーバーで検査します。
    • 重複の確認は「確認しただけ」:今回は保存の前に「重なる予約が無いか」を調べていますが、調べてから保存するまでのわずかな間に別の人が予約すると、すり抜けます。DB 側で確実に防ぐ方法を第4回で扱います。
    • 完了画面に個人情報を出さない:完了画面は予約番号を URL に含むため、番号を変えると他人の画面が見えかねません。そこで、名前やメールアドレスは表示せず、時間と金額だけにしています。本格的には、第5回の会員ログインで「本人の予約だけ見える」ようにします。
    • 連載で省いていること:迷惑な連続送信を防ぐ対策(ボット対策・送信回数の制限)、確認メールの送信(第8回)、個人情報の取り扱い方針の掲示は、この回では実装していません。

    発注者向けメモ:予約フォームを開発会社に頼むときの確認点

    予約フォームは、画面は単純に見えても、裏側の「ルール」で工数が大きく変わります。個人情報を預かる機能でもあるため、次の点を事前に確認しておくと、見積もりのずれを防げます。金額は案件ごとに異なるため、ここでは書きません。

    • ☐ 予約のルール(最長の利用時間、何日先まで予約できるか、営業時間、直前の締め切り)が決まっているか
    • ☐ 取得する個人情報の項目と、利用目的・保管期間が決まっているか(個人情報保護法の扱いは、個人情報保護委員会の公式情報を確認し、個別の判断は専門家へ)
    • ☐ 入力チェックは「画面」だけでなく「サーバー側」でも行う設計か
    • ☐ 料金の計算を、サーバー側で行う設計か
    • ☐ 迷惑な連続送信への対策(ボット対策など)は、今回の範囲に含まれるか
    • ☐ 同時に予約が重なったときの扱いが、仕様として決まっているか

    開発会社への質問例は次のとおりです。

    • 「予約フォームの入力チェックは、サーバー側でも行いますか?」
    • 「予約が重なった場合、どの段階で、どのように防ぎますか?」
    • 「予約者の個人情報は、どこに、どのくらいの期間保管されますか?」

    ルールが曖昧なまま作り始めると、「キャンセルはいつまで可能か」「利用時間の単位は 30 分か」といった追加の相談で、仕様変更や追加費用の原因になりやすい箇所です。

    まとめと次回予告

    • Server Actions を使うと、API を別に作らずに、フォームから呼ぶサーバー処理を 1 つの関数で書ける
    • ただし入口は公開されているので、入力の検査は必ずサーバーで行い、金額もサーバーで計算する
    • 検査は「形(Zod)」と「予約として成り立つか」に分けると、テストしやすい
    • 今回の重複確認は完全ではない。確実な防止が次回の主題

    次回の第4回は、ダブルブッキングを防ぐ(PostgreSQL の排他制約・トランザクション・同時アクセスのテスト)です。第3回で見た「確認から保存までの隙間」を、データベース側の仕組みで塞ぎます。

    この連載の記事一覧

    この記事は連載「Next.jsで作るオンライン予約サービス」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      目次