MENU

問い合わせ


    【Next.jsで作るオンライン予約サービス 第1回】スペース一覧と詳細ページを作る(Server Components・Drizzle・メタデータ)

    第0回では、Next.js 16 の雛形を動かし、PostgreSQL への接続とテストまで通しました。第1回では、予約サービスの入り口になる スペースの一覧ページと詳細ページ を作ります。

    「予約画面を作る前に、まず何から手を付ければいいのだろう。データベースの表はどう決めればいい?」

    結論から言うと、予約サービスは「何を予約するのか(スペース)」の表から作り始めるのが安全です。 予約の表は、スペースの表を参照して作るため、後から作り直しにくい土台になるからです。この回では、Drizzle ORM(データベースの表をプログラム上の部品として扱う道具)でスペースの表を定義し、Server Components(サーバー側で描画される部品)で一覧と詳細を表示します。検索結果に出るタイトル・説明文(メタデータ)も、スペースごとに作ります。

    目次

    この回で作るもの

    作るもの場所役割
    スペースの表lib/schema.ts名前・エリア・収容人数・料金を保存
    取得の関数lib/spaces.ts一覧と、URLの名前(slug)での1件取得
    一覧ページapp/spaces/page.tsx/spaces
    詳細ページapp/spaces/[slug]/page.tsx/spaces/shibuya-a など
    開発用データlib/seed-data.ts・scripts/seed.ts架空の3スペースを登録

    検証環境は Node.js 22系・PostgreSQL 16・Next.js 16・React 19 です(執筆時点の2026年10月)。

    スペースの表をDrizzleで定義する

    まず、表の形をTypeScriptで書きます。Drizzle では、この定義が「データベースの表」と「プログラム上の型」の両方の元になります。

    // lib/schema.ts
    import { integer, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
    
    export const spaces = pgTable("spaces", {
      id: serial("id").primaryKey(),
      slug: text("slug").notNull().unique(), // URLに使う短い名前(例: shibuya-a)
      name: text("name").notNull(),
      area: text("area").notNull(), // 最寄りの地域(例: 渋谷)
      description: text("description").notNull(),
      capacity: integer("capacity").notNull(), // 収容人数
      hourlyPriceYen: integer("hourly_price_yen").notNull(), // 1時間あたりの料金(税込・円)
      createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
    });
    
    export type Space = typeof spaces.$inferSelect;

    ポイントは3つです。

    • slug に重複禁止(unique)を付ける:/spaces/shibuya-a のように、URLからスペースを1件に特定するためです。
    • 料金は円の整数で持つ:小数にすると端数の誤差が出るため、金額は整数で扱うのが定石です。後の決済の回でも効いてきます。
    • 日時はタイムゾーン付き(withTimezone)にする:予約の回で時刻を扱うので、今のうちに揃えておきます。

    表をデータベースに作る(マイグレーション)

    表の定義から、データベースに表を作るためのSQLを生成します。これを「マイグレーション(=データベースの構造変更を、手順書のファイルとして残して適用する仕組み)」と呼びます。

    // drizzle.config.ts
    import { defineConfig } from "drizzle-kit";
    
    export default defineConfig({
      dialect: "postgresql",
      schema: "./lib/schema.ts",
      out: "./drizzle",
      dbCredentials: { url: process.env.DATABASE_URL! },
    });
    npm run db:generate   # drizzle/0000_*.sql が作られる
    npm run db:migrate    # データベースに適用する

    package.json には "db:generate": "drizzle-kit generate"、"db:migrate": "drizzle-kit migrate" を追加しています。生成されたSQLはGitに入れて、本番環境にも同じ手順で適用します。

    📰 出典:Drizzle ORM Docs「Migrations」

    開発用のデータを入れる

    画面を作るには、見るためのデータが要ります。架空の3スペースを用意し、npm run db:seed で登録します。

    // scripts/seed.ts
    import { createDb } from "../lib/db.ts";
    import { sampleSpaces } from "../lib/seed-data.ts";
    import { spaces } from "../lib/schema.ts";
    
    const { db, client } = createDb();
    for (const space of sampleSpaces) {
      await db
        .insert(spaces)
        .values(space)
        .onConflictDoUpdate({ target: spaces.slug, set: space });
    }
    console.log(`${sampleSpaces.length} 件のスペースを登録しました`);
    await client.end();

    onConflictDoUpdate は「同じ slug が既にあれば上書きする」指定です。何度実行しても件数が増えないので、開発中に気軽にやり直せます。実行は node scripts/seed.ts で、Node.js 22系の標準機能でTypeScriptをそのまま動かしています(追加の道具は不要)。そのため、このスクリプトからたどるファイルの import には .ts を付けています。

    データを取り出す関数を作る

    画面からデータベースを呼ぶ処理は、1か所の関数にまとめます。

    // lib/spaces.ts
    import { asc, eq } from "drizzle-orm";
    import { getDb } from "./db";
    import { spaces } from "./schema";
    
    // 一覧は料金の安い順。同額なら名前順
    export async function listSpaces() {
      return getDb().select().from(spaces).orderBy(asc(spaces.hourlyPriceYen), asc(spaces.name));
    }
    
    // 見つからなければ undefined を返す(画面側で 404 にする)
    export async function findSpaceBySlug(slug: string) {
      const rows = await getDb().select().from(spaces).where(eq(spaces.slug, slug)).limit(1);
      return rows[0];
    }

    画面のあちこちでSQLを書かず、関数にまとめておくと、後で並び順や絞り込みを変えるときの修正が1か所で済みます。

    なお、第0回の createDb()(使い終わったら閉じる用)に加えて、画面から使う共有の接続 getDb() を lib/db.ts に足しました。開発中にコードを書き換えるたび、接続が増え続けるのを防ぐため、接続を globalThis に1つだけ保持しています。

    // lib/db.ts(追加分)
    const globalForDb = globalThis as unknown as { db?: ReturnType<typeof createDb>["db"] };
    
    export function getDb() {
      globalForDb.db ??= drizzle(postgres(connectionUrl(), { max: 5 }), { schema });
      return globalForDb.db;
    }

    一覧ページをServer Componentで作る

    一覧ページは、サーバー側で描画される部品(Server Component)として書きます。ポイントは、部品を async にして、データベースを直接呼べることです。別途APIを作ってブラウザから呼ぶ必要がありません。

    // app/spaces/page.tsx
    import type { Metadata } from "next";
    import { SpaceCard } from "@/components/space-card";
    import { listSpaces } from "@/lib/spaces";
    
    export const metadata: Metadata = {
      title: "スペース一覧",
      description: "貸し会議室の一覧です。エリア・収容人数・料金を比べて選べます。",
    };
    
    // DB の内容を毎回表示するため、ビルド時ではなくリクエストごとに描画する
    export const dynamic = "force-dynamic";
    
    export default async function SpacesPage() {
      const spaces = await listSpaces();
      return (
        <main>
          <h1>スペース一覧</h1>
          {spaces.length === 0 ? (
            <p>現在、ご案内できるスペースはありません。</p>
          ) : (
            <ul>
              {spaces.map((space) => (
                <SpaceCard key={space.id} space={space} />
              ))}
            </ul>
          )}
        </main>
      );
    }

    export const dynamic = "force-dynamic" は、「ビルド時に固定せず、アクセスのたびにデータベースから取り直す」指定です。これを付けないと、ビルド時にデータベースへ接続しようとして失敗したり、古い一覧が表示され続けたりします。一覧が頻繁に変わらないサービスでは、キャッシュ(一度作った結果を使い回す仕組み)で高速化する選択肢もありますが、予約の空き状況に関わる画面は、まず「毎回最新」から始めるのが安全です。

    1件分の表示は、別の部品に切り出します。

    // components/space-card.tsx
    import Link from "next/link";
    import { formatYen } from "@/lib/format";
    import type { Space } from "@/lib/schema";
    
    export function SpaceCard({ space }: { space: Space }) {
      return (
        <li>
          <h2>
            <Link href={`/spaces/${space.slug}`}>{space.name}</Link>
          </h2>
          <p>
            {space.area} / 最大{space.capacity}名 / {formatYen(space.hourlyPriceYen)}(1時間)
          </p>
        </li>
      );
    }

    料金の表示は、ブラウザにも備わっている Intl.NumberFormat(数値を地域ごとの書式に整える標準機能)を使っています。

    // lib/format.ts
    export function formatYen(amount: number): string {
      return new Intl.NumberFormat("ja-JP", { style: "currency", currency: "JPY" }).format(amount);
    }

    詳細ページと、スペースごとのメタデータ

    詳細ページは、URLの一部([slug] の部分)を受け取って1件表示します。フォルダ名を [slug] にすると、/spaces/shibuya-a のような任意のURLに対応します。

    // app/spaces/[slug]/page.tsx
    type Props = { params: Promise<{ slug: string }> }; // Next.js 16 では params は Promise
    
    export async function generateMetadata({ params }: Props): Promise<Metadata> {
      const { slug } = await params;
      const space = await findSpaceBySlug(slug);
      if (!space) return { title: "スペースが見つかりません" };
      return {
        title: `${space.name}(${space.area})`,
        description: `${space.description} 最大${space.capacity}名・${formatYen(space.hourlyPriceYen)}/時間。`,
      };
    }
    
    export default async function SpaceDetailPage({ params }: Props) {
      const { slug } = await params;
      const space = await findSpaceBySlug(slug);
      if (!space) notFound(); // 404 ページを表示する
      // ……名前・説明・エリア・収容人数・料金を表示(全体はサンプルの code/ を参照)
    }

    押さえておきたい点は次の3つです。

    • params は Promise(あとで値が届く約束):Next.js 16 では await で取り出します。書き忘れると型エラーになるので、気づけます。
    • generateMetadata で検索結果の見た目を作る:ページごとのタイトルと説明文をデータから作れます。検索から来てもらいたいスペースの詳細ページで、同じタイトルが並ぶのを防げます。
    • 存在しない slug は notFound() で404にする:404のページには自動で「検索に載せない」指定(noindex)が付くことを、今回の確認でも見ています。

    📰 出典:Next.js Docs「generateMetadata」

    動かして確認する

    DBを用意し、マイグレーションとデータ登録をしてから起動します。

    export DATABASE_URL=postgres://yoyaku:yoyaku@localhost:5432/yoyaku
    npm run db:migrate && npm run db:seed
    npm run build && npm start

    検証環境で次のとおり確認できました。

    確認結果
    /spaces200。3件が料金の安い順に表示
    /spaces/shibuya-a200。タイトル「渋谷ミーティングルームA(渋谷)」、説明文もスペース固有のもの
    /spaces/nothing404(noindex 付き)
    npm run lint・npx tsc --noEmit・npm run build成功
    npm test8件成功(DBに接続できるときは、並び順と slug 検索も実際のDBで確認)

    ※ 画面はまだ装飾(CSS)を付けていません。次の画像は開発環境(Linux)での確認画面です。

    スペース一覧ページの確認画面(開発環境)
    スペース詳細ページの確認画面(開発環境)

    つまずきやすい点

    • ビルドが「DBに接続できない」で失敗する:DBを読むページに dynamic = "force-dynamic" を付けていないと、ビルド時に接続しようとします。
    • params を await し忘れる:Next.js 16 では Promise です。型エラーで気づけるので、npm run typecheck を習慣にしましょう。
    • DATABASE_URL を書き忘れる:.env.example をコピーして .env を作ります。パスワードを含む .env は Git に入れません。
    • 料金を小数や文字列で持つ:金額は整数の円で持つと、決済の回で端数の問題を避けられます。

    なお、本番運用に必要でもこの連載では省略するものがあります(画像の配信、掲載の公開/非公開の切り替え、大量データでのページ分け)。必要になった時点で追加します。

    発注者向けメモ:一覧・詳細ページを頼むときの確認点

    予約サービスの最初の画面は簡単に見えますが、後の工数に響く決め事が含まれます。開発会社に頼むときは、次を確認してみてください。

    • ☐ 1スペースに、どんな情報を載せるか(写真・設備・住所・キャンセル規定など)を決めたか
    • ☐ 料金は「時間単価のみ」か、「時間帯別・曜日別・人数別」まであるか(後者は設計の工数が増えます)
    • ☐ 掲載するスペースを誰が追加・修正するか(管理画面を作るか、最初は開発会社が登録するか)
    • ☐ 検索から来てもらいたいか(タイトル・説明文・URL設計の工数に関わります)
    • ☐ スペースの数が増えたとき、検索や絞り込みが必要か

    開発会社への質問例です。

    • 「スペースの追加や料金の変更は、管理画面で私たちが行えますか。それとも依頼が必要ですか」
    • 「料金が時間帯や曜日で変わる場合、見積もりはどう変わりますか」
    • 「検索結果に表示されるタイトルと説明文は、スペースごとに設定できますか」
    • 「データベースの構造を変更するとき、既存データはどう守られますか」

    まとめと次回予告

    第1回では、次のことを行いました。

    • Drizzle でスペースの表を定義し、マイグレーションでデータベースに作成した
    • 開発用データを何度でも入れ直せる db:seed を用意した
    • Server Component で、データベースから直接スペースの一覧と詳細を表示した
    • generateMetadata で、スペースごとのタイトルと説明文を作った

    次回の第2回では、空き状況カレンダー を作ります。時間枠の考え方と、日本時間(JST)の扱いが中心です。予約システムでは、時刻の扱いの小さな勘違いが二重予約や「1日ずれる」不具合につながるため、ここを丁寧に整理します。サンプルコード全体は、連載の code/ ディレクトリにまとめています。

    この連載の記事一覧

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

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


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

      この記事を書いた人

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

      コメント

      目次