第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
検証環境で次のとおり確認できました。
| 確認 | 結果 |
|---|---|
/spaces | 200。3件が料金の安い順に表示 |
/spaces/shibuya-a | 200。タイトル「渋谷ミーティングルームA(渋谷)」、説明文もスペース固有のもの |
/spaces/nothing | 404(noindex 付き) |
npm run lint・npx tsc --noEmit・npm run build | 成功 |
npm test | 8件成功(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回です。連載のほかの回は次のとおりです(連載の一覧ページ)。
- 【Next.jsで作るオンライン予約サービス 第0回】完成像とNext.js 16の雛形を動かす(App Router・PostgreSQL・Drizzle)
- 【Next.jsで作るオンライン予約サービス 第1回】スペース一覧と詳細ページを作る(Server Components・Drizzle・メタデータ)(この記事)










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