The room booking tutorial ends with "check again on the server". This one builds that server. You get a Next.js App Router app where rooms and bookings live in Postgres through Prisma, the calendar is Resource Scheduler in a client component, and the route handlers decide whether a booking is allowed.

Every file is in the steps below, so you can copy them in order. The finished app is also in the repository as the resource-scheduler-rooms-starter folder; fetch just that folder with npx degit mdobaid311/resource-scheduler/resource-scheduler-rooms-starter rooms.

See the browser side running live: interactive demo (it has no server, so its data resets on reload).

Drag across free time on a room to open the form. The app you build adds three things to it: the data comes from a database, a save can be refused by the server, and a refused save rolls back on screen.

Versions. The snippets are written for Next.js 15 (in 14, params in a route handler is not a Promise) and Prisma 6 with Postgres. Newer Prisma majors changed how the client is generated and how the connection is configured, so follow the Prisma docs for that part. The queries and the transaction do not depend on it.

How the pieces fit

  1. The server component app/page.tsx reads rooms and bookings with Prisma and passes them as plain strings.
  2. The client component app/board.tsx renders ResourceScheduler and sends changes to the API.
  3. The route handlers under app/api/bookings call one function, which re-checks the placement with isPlacementAllowed inside a transaction.
  4. The browser and the server share one object, bookingRules, so they cannot disagree about what "free" means.

1. Create the app

npx create-next-app@latest rooms --ts --app cd rooms npm install resource-scheduler @prisma/client npm install -D prisma@6 npx prisma init --datasource-provider postgresql 

Put your Postgres connection string in .env as DATABASE_URL. Any Postgres works, local or hosted.

2. The schema

Store times as DateTime. Prisma saves them in UTC, and the end is exclusive: a booking that ends at 10:00 does not collide with one that starts at 10:00.

prisma/schema.prisma

generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model Room { id String @id @default(cuid()) name String seats Int bookings Booking[] } model Booking { id String @id @default(cuid()) title String roomId String room Room @relation(fields: [roomId], references: [id]) startsAt DateTime endsAt DateTime createdAt DateTime @default(now()) @@index([roomId, startsAt]) } 

Create the tables and two rooms to play with:

npx prisma migrate dev --name init 

prisma/seed.ts

import { PrismaClient } from "@prisma/client"; const prisma = new PrismaClient(); async function main() { await prisma.room.createMany({ data: [ { name: "Atlas", seats: 8 }, { name: "Borealis", seats: 4 }, ], }); } main().finally(() => prisma.$disconnect()); 
npx tsx prisma/seed.ts 

3. Shared code

One Prisma client for the whole dev server (Next.js reloads modules, and without this every reload opens a new connection pool):

lib/db.ts

import { PrismaClient } from "@prisma/client"; const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; 

The rule, in one place. The client spreads it onto the component, and the server hands the same object to isPlacementAllowed:

lib/rules.ts

export const bookingRules = { eventOverlap: false as const }; 

A booking crosses the server boundary and the network as JSON, where a Date becomes a string. This is the shape on the wire:

lib/dto.ts

import type { Booking } from "@prisma/client"; export type BookingDTO = { id: string; title: string; roomId: string; startsAt: string; endsAt: string; }; export const toDTO = (b: Booking): BookingDTO => ({ id: b.id, title: b.title, roomId: b.roomId, startsAt: b.startsAt.toISOString(), endsAt: b.endsAt.toISOString(), }); 

4. The page loads rooms and bookings

A server component can read the database directly. It loads the rooms and a window of bookings around now (for a long range, fetch per visible range with onRangeChange, see Loading and control). It also passes the current time as a string, so the first render on the server and in the browser start on the same day. See Next.js and SSR.

app/page.tsx

import { prisma } from "@/lib/db"; import { toDTO } from "@/lib/dto"; import { Board } from "./board"; export const dynamic = "force-dynamic"; // read the database per request, not at build time const DAY = 24 * 60 * 60 * 1000; export default async function Page() { const now = new Date(); const [rooms, bookings] = await Promise.all([ prisma.room.findMany({ orderBy: { name: "asc" } }), prisma.booking.findMany({ where: { endsAt: { gt: new Date(now.getTime() - DAY) }, startsAt: { lt: new Date(now.getTime() + 14 * DAY) }, }, }), ]); return ( <main style={{ padding: 16 }}> <Board rooms={rooms.map((r) => ({ id: r.id, name: `${r.name} (${r.seats} seats)` }))} initial={bookings.map(toDTO)} initialDate={now.toISOString()} /> </main> ); } 

5. The server decides

This is the file the whole tutorial is for. A booking is allowed only if isPlacementAllowed says so for the room's current bookings, and the check and the write happen in one Serializable transaction, so two requests that both read "free" cannot both commit.

lib/bookings.ts

import { Prisma } from "@prisma/client"; import { isPlacementAllowed } from "resource-scheduler/headless"; import type { Resource } from "resource-scheduler/headless"; import { prisma } from "./db"; import { bookingRules } from "./rules"; export class Conflict extends Error {} export class NotFound extends Error {} type Slot = { roomId: string; start: Date; end: Date }; const inTransaction = <T>(run: (tx: Prisma.TransactionClient) => Promise<T>) => prisma.$transaction(run, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }); // Throws Conflict unless the booking `id` (none, for a new one) may take this slot. async function assertFree(tx: Prisma.TransactionClient, id: string | undefined, { roomId, start, end }: Slot) { const room = await tx.room.findUnique({ where: { id: roomId }, include: { bookings: { where: { startsAt: { lt: end }, endsAt: { gt: start } } } }, }); if (!room) throw new NotFound(); const resource: Resource = { id: room.id, name: room.name, events: room.bookings.map((b) => ({ id: b.id, title: b.title, startDate: b.startsAt, endDate: b.endsAt })), }; const moving = { id: id ?? "new", title: "", startDate: start, endDate: end }; if (!isPlacementAllowed(moving, { resourceId: roomId, start, end }, [resource], bookingRules)) { throw new Conflict(); } } export const createBooking = (slot: Slot & { title: string }) => inTransaction(async (tx) => { await assertFree(tx, undefined, slot); return tx.booking.create({ data: { roomId: slot.roomId, title: slot.title, startsAt: slot.start, endsAt: slot.end }, }); }); export const moveBooking = (id: string, slot: Slot) => inTransaction(async (tx) => { if (!(await tx.booking.findUnique({ where: { id } }))) throw new NotFound(); await assertFree(tx, id, slot); // the booking's own old time is ignored: its id is `id` return tx.booking.update({ where: { id }, data: { roomId: slot.roomId, startsAt: slot.start, endsAt: slot.end }, }); }); 

The query already narrows to bookings that overlap the slot, and isPlacementAllowed applies the same overlap rule the browser used, including ignoring the booking being moved. Under Serializable, Postgres aborts one of two conflicting transactions when it commits; Prisma reports that as error P2034, which the next file also turns into a 409 (retrying once is an equally good answer).

Two small helpers turn requests into slots and errors into responses:

lib/http.ts

import { Prisma } from "@prisma/client"; import { Conflict, NotFound } from "./bookings"; // body comes from request.json(), so nothing in it is trusted. export function parseSlot(body: { roomId?: unknown; startsAt?: unknown; endsAt?: unknown }) { const start = new Date(body.startsAt as string); const end = new Date(body.endsAt as string); if (typeof body.roomId !== "string" || !(start < end)) return null; // also catches Invalid Date return { roomId: body.roomId, start, end }; } export function fail(error: unknown) { if (error instanceof Conflict || (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2034")) { return Response.json({ error: "That time was just booked." }, { status: 409 }); } if (error instanceof NotFound) return Response.json({ error: "Not found." }, { status: 404 }); throw error; } 

The route handlers are thin:

app/api/bookings/route.ts

import { createBooking } from "@/lib/bookings"; import { toDTO } from "@/lib/dto"; import { fail, parseSlot } from "@/lib/http"; export async function POST(request: Request) { const body = await request.json().catch(() => ({})); const slot = parseSlot(body); const title = typeof body.title === "string" ? body.title.trim() : ""; if (!slot || !title) return Response.json({ error: "Invalid booking." }, { status: 400 }); try { return Response.json(toDTO(await createBooking({ ...slot, title })), { status: 201 }); } catch (error) { return fail(error); } } 

app/api/bookings/[id]/route.ts

import { moveBooking } from "@/lib/bookings"; import { toDTO } from "@/lib/dto"; import { fail, parseSlot } from "@/lib/http"; export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const slot = parseSlot(await request.json().catch(() => ({}))); if (!slot) return Response.json({ error: "Invalid booking." }, { status: 400 }); try { return Response.json(toDTO(await moveBooking(id, slot))); } catch (error) { return fail(error); } } 

This app has no sign-in. Anyone who can reach these routes can book and move anything. Before real use, check the user in each handler, and let a person change only their own bookings.

6. The calendar, with optimistic updates

The client component keeps the bookings in state and renders them through the flat events prop, each with a resourceId, which maps straight onto database rows. The key idea of an optimistic update: show the change at once, send it, and undo it if the server says no. A booking that is still being saved is drawn grey and has a temporary id.

app/board.tsx

"use client"; import { useState } from "react"; import { ResourceScheduler, ViewType } from "resource-scheduler"; import type { SchedulerEvent, SlotSelection } from "resource-scheduler"; import "resource-scheduler/dist/resource-scheduler.css"; import type { BookingDTO } from "@/lib/dto"; import { bookingRules } from "@/lib/rules"; type Room = { id: string; name: string }; const PENDING = "pending-"; const toEvent = (b: BookingDTO): SchedulerEvent => ({ id: b.id, title: b.title, resourceId: b.roomId, startDate: new Date(b.startsAt), endDate: new Date(b.endsAt), }); async function send(url: string, method: "POST" | "PATCH", body: unknown): Promise<BookingDTO> { const response = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), // a Date becomes an ISO string }); if (response.status === 409) throw new Source:https://dev.to/mdobaid311/room-booking-app-with-nextjs-and-prisma-36j4

Related posts