Skip to content

Add bookings to a Next.js app

This page adds real bookings to a Next.js app that uses the App Router: a customer picks a day, sees the free times, books one, and your server hears about it through a signed webhook. Two people pressing the same time at the same moment get one booking and one clear refusal, because the capacity check is a Postgres constraint inside Bookrail and not code in your app.

What you need first:

  • A Next.js app with the App Router and TypeScript. The route handlers below use only the Web Request and Response that Next.js route handlers are built on, so nothing here depends on a Next.js version-specific API.
  • A Bookrail key and one service. npx bookrail signup gives you a sk_test_ key, and npx bookrail init --template padel followed by npx bookrail push creates a service in about a minute: the Quickstart walks through both. The svc_... id is in the output of npx bookrail services list.

npx bookrail init --framework nextjs also exists: it writes a small client on plain fetch, with no dependency. This page uses the SDK instead, for its typed errors, its retries and its webhook verification.

How this page is checked. Every TypeScript file on this page is extracted from this markdown by packages/sdk-node/test/guides.test.ts, type checked against @bookrail/node, and the server files are run against a local Bookrail API on Postgres: availability, a booking, the same booking retried, the slot taken twice, a webhook delivered and verified by the real dispatcher, and a forged one refused. The form is type checked and not run, because it needs a browser. The test does not edit the code it runs: it sends the calls the SDK makes to https://api.bookrail.dev to the local API instead.

Terminal window
npm install @bookrail/node
.env.local
BOOKRAIL_SECRET_KEY=sk_test_...
BOOKRAIL_SERVICE_ID=svc_...
BOOKRAIL_WEBHOOK_SECRET=whsec_...

The secret key never gets the NEXT_PUBLIC_ prefix. Next.js inlines those variables into the JavaScript it sends to the browser, and a Bookrail secret key can create, change and cancel every booking of the project. Without the prefix a variable exists only on the server, which is where every call below runs. BOOKRAIL_WEBHOOK_SECRET comes from step 6; leave it empty until then.

lib/bookrail.ts
import 'server-only';
import Bookrail, { BookrailError } from '@bookrail/node';
let client: Bookrail | undefined;
/** The Bookrail client, built on first use so that a build without the key does not fail. */
export function bookrail(): Bookrail {
client ??= new Bookrail(process.env.BOOKRAIL_SECRET_KEY!);
return client;
}
export const SERVICE_ID = process.env.BOOKRAIL_SERVICE_ID!;
/**
* A Bookrail error as the browser should see it: a status and a code, nothing else.
* `fix`, when the API sends one, is an instruction for whoever maintains this app,
* so it goes to the server log together with the request id you would quote to support.
*/
export function errorResponse(error: unknown): Response {
if (!(error instanceof BookrailError)) throw error;
console.error(`bookrail ${error.code}: ${error.message}`, {
fix: error.fix,
request_id: error.requestId,
});
return Response.json({ code: error.code }, { status: error.status ?? 502 });
}

import 'server-only' turns an accidental import of this file from a client component into a build error (Next.js handles it without the package being installed). The constructor refuses a key that is missing or does not start with sk_test_ or sk_live_, synchronously; building the client on first use keeps that check away from a build machine that has no key.

The browser never talks to Bookrail. It talks to these two handlers, which hold the key.

app/api/availability/route.ts
import { bookrail, errorResponse, SERVICE_ID } from '@/lib/bookrail';
/** GET /api/availability?from=...&to=... : the free slots between two instants. */
export async function GET(request: Request) {
const query = new URL(request.url).searchParams;
try {
const availability = await bookrail().availability.list({
service_id: SERVICE_ID,
from: query.get('from') ?? '',
to: query.get('to') ?? '',
});
return Response.json({
timezone: availability.timezone,
slots: (availability.slots ?? []).map((slot) => ({
start: slot.start,
end: slot.end,
duration_minutes: slot.duration_minutes,
})),
});
} catch (error) {
return errorResponse(error);
}
}

from and to are instants with an explicit offset, such as 2026-10-05T00:00:00+02:00. A bare date is refused with 400 parameter_invalid, because midnight is not the same instant in every time zone; the handler passes that refusal on instead of guessing. The slots come back in UTC, with the time zone of the place, so the form can show them on the clock of the club rather than on the clock of the visitor.

app/api/bookings/route.ts
import { bookrail, errorResponse, SERVICE_ID } from '@/lib/bookrail';
interface BookingRequest {
start: string;
duration_minutes: number;
name: string;
email: string;
/** One per attempt, made by the form: a double click or a retry books once. */
attempt: string;
}
export async function POST(request: Request) {
const input = (await request.json()) as BookingRequest;
try {
const booking = await bookrail().bookings.create(
{
service_id: SERVICE_ID,
start: input.start,
duration_minutes: input.duration_minutes,
customer: { email: input.email, name: input.name },
},
{ idempotencyKey: `booking-form-${input.attempt}` },
);
return Response.json(
{ id: booking.id, status: booking.status, start: booking.start, end: booking.end },
{ status: 201 },
);
} catch (error) {
return errorResponse(error);
}
}

The idempotency key is the part that is easy to leave out and expensive to miss. The SDK already sends one on every POST and reuses it on its own retries; passing attempt extends the same guarantee to the retries the browser makes, so a customer who clicks twice, or whose connection drops after the booking was made, still has one booking. The same key with the same body answers with the first booking again.

When the slot is gone, bookings.create throws a BookrailConflictError with the code slot_unavailable, and the handler answers 409 with that code. Nobody else was allowed in: the edge cases page has the test that fires simultaneous requests from separate processes at one slot.

app/book/booking-form.tsx
'use client';
import { useState } from 'react';
interface Slot {
start: string;
end: string;
duration_minutes: number;
}
const MESSAGES: Record<string, string> = {
slot_unavailable: 'Somebody took that time a moment ago. Please pick another one.',
parameter_invalid: 'That date could not be read. Please pick it again.',
};
export default function BookingForm() {
const [timeZone, setTimeZone] = useState('UTC');
const [slots, setSlots] = useState<Slot[]>([]);
const [chosen, setChosen] = useState<Slot | null>(null);
const [message, setMessage] = useState('');
const [attempt, setAttempt] = useState(() => crypto.randomUUID());
async function showDay(date: string) {
// The visitor's day, as two instants: the browser knows its own offset, the API needs one.
const from = new Date(`${date}T00:00`);
const to = new Date(from);
to.setDate(to.getDate() + 1);
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() });
const response = await fetch(`/api/availability?${query.toString()}`);
const body = await response.json();
if (!response.ok) {
setMessage(MESSAGES[body.code] ?? 'Something went wrong. Please try again.');
return;
}
setTimeZone(body.timezone);
setSlots(body.slots);
setChosen(null);
setMessage(body.slots.length === 0 ? 'Nothing free that day.' : '');
}
async function book(form: HTMLFormElement) {
if (chosen === null) return;
const data = new FormData(form);
const response = await fetch('/api/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
start: chosen.start,
duration_minutes: chosen.duration_minutes,
name: data.get('name'),
email: data.get('email'),
attempt,
}),
});
const body = await response.json();
if (response.ok) {
setMessage(`Booked. Your reference is ${body.id}.`);
setSlots([]);
setChosen(null);
} else {
setMessage(MESSAGES[body.code] ?? 'Something went wrong. Please try again.');
}
setAttempt(crypto.randomUUID());
}
const time = new Intl.DateTimeFormat(undefined, { timeStyle: 'short', timeZone });
return (
<form
onSubmit={(event) => {
event.preventDefault();
void book(event.currentTarget);
}}
>
<label>
Day <input type="date" required onChange={(event) => void showDay(event.target.value)} />
</label>
<fieldset>
{slots.map((slot) => (
<button
key={`${slot.start}/${slot.duration_minutes}`}
type="button"
aria-pressed={chosen === slot}
onClick={() => setChosen(slot)}
>
{time.format(new Date(slot.start))} ({slot.duration_minutes} min)
</button>
))}
</fieldset>
<label>
Name <input name="name" required />
</label>
<label>
Email <input name="email" type="email" required />
</label>
<button type="submit" disabled={chosen === null}>
Book
</button>
<p role="status">{message}</p>
</form>
);
}
app/book/page.tsx
import BookingForm from './booking-form';
export default function BookPage() {
return (
<main>
<BookingForm />
</main>
);
}

The form is deliberately plain: no library, no styling, and every piece of state visible. The two details worth keeping when you restyle it are the ones in the comments: the day is turned into two instants in the browser, and attempt changes only after an answer, so a retry of the same attempt cannot book twice.

Every refusal of the API has a type, a code, a message, often a param, and a request id. The SDK turns it into a class per family (BookrailConflictError, BookrailInvalidRequestError, and the others listed in the SDK reference). The ones this form will meet:

CodeStatusWhat the form says
slot_unavailable409The time was taken in the meantime: pick another.
parameter_invalid400A field was malformed, such as a date without an offset. param names it.
plan_limit_reached402A live key on the Free plan used this month’s bookings (Pricing).
rate_limited429Too many calls in a second. The SDK already waited and retried before throwing.

Some errors also carry fix: one sentence that says what to do next, sent when the API has a single instruction to give, as it does for a rate limit or a plan limit. It is written for a developer or for the coding agent reading your logs, not for a customer, which is why errorResponse logs it and sends the browser only the code. Every code is on the Errors page.

A booking can change without your app asking: it is cancelled from the dashboard or the CLI, it completes when its time passes, a hold expires. Bookrail tells you with a signed POST.

Register the endpoint once, with the key of the environment it is for. This script prints the signing secret, which is shown this one time and never again:

scripts/create-webhook.mts
import Bookrail from '@bookrail/node';
const bookrail = new Bookrail(process.env.BOOKRAIL_SECRET_KEY!);
const endpoint = await bookrail.webhooks.create({
url: 'https://example.com/api/webhooks/bookrail',
events: ['booking.created', 'booking.cancelled', 'booking.rescheduled'],
});
// Shown once. Put it in BOOKRAIL_WEBHOOK_SECRET now: no endpoint returns it again.
console.log(`${endpoint.id} ${endpoint.secret}`);

Run it with npx tsx scripts/create-webhook.mts and the key in the environment, or do the same from the terminal with npx bookrail webhooks create --url ... --events booking.created. Then the receiver:

app/api/webhooks/bookrail/route.ts
import { BookrailSignatureVerificationError, type Booking, type Event } from '@bookrail/node';
import { bookrail } from '@/lib/bookrail';
export async function POST(request: Request) {
// The raw text, exactly as it arrived: a body that was parsed and re-encoded does not verify.
const payload = await request.text();
let event: Event;
try {
event = bookrail().webhooks.constructEvent(
payload,
request.headers.get('bookrail-signature'),
process.env.BOOKRAIL_WEBHOOK_SECRET!,
);
} catch (error) {
if (error instanceof BookrailSignatureVerificationError) {
return new Response('invalid signature', { status: 400 });
}
throw error;
}
if (event.type.startsWith('booking.')) {
const booking = event.data.object as unknown as Booking;
// Your code goes here: an email, a row in your own database, a message to the club.
// Delivery is at least once, so make it safe to run twice for the same event.id.
console.log(`${event.id} ${event.type} ${booking.id} ${booking.status}`);
}
return new Response(null, { status: 204 });
}

Three things make a receiver correct, and all three are in those lines. The body is read with request.text(), never request.json(), because the signature is over the bytes. A signature that does not match answers 400, and constructEvent also refuses a timestamp older than five minutes, so a captured delivery cannot be replayed later. And the handler is safe to run twice: anything that is not a 2xx is retried on a ladder from three seconds to a day, and a delivery can arrive twice even when everything went well. Webhooks has the ladder, the headers and the rule about which URLs are allowed.

To receive deliveries on your laptop, point a tunnel (ngrok, cloudflared) at next dev and register the tunnel’s URL, or run npx bookrail webhooks listen, which follows the same events from the event log without a public URL.

The live key is the other key npx bookrail signup gave you, sk_live_.... Put it in the production environment of your host in place of the test key, create the webhook endpoint again with it (endpoints and their secrets belong to one environment), and set the new BOOKRAIL_WEBHOOK_SECRET. Test and live are separate worlds: nothing you created with the test key exists for the live one, so run npx bookrail push --live for your services first and set BOOKRAIL_SERVICE_ID to the id npx bookrail services list --live prints. A person decides this step. A coding agent can prepare every file above with the test key, and should stop and ask before anything is done with a live one.

  • Payments. A deposit or the full price on the booking is payment: { mode: 'deposit' } on the same call, once a Stripe account is connected: Payments with Stripe.
  • Holds. Reserving the slot for ten minutes while the customer fills in a longer form is bookrail().holds.create(...) and then hold_id on the booking: Concepts.
  • Cancelling from your app. bookrail().bookings.cancel(id) applies the refund tiers of the policy the customer booked under, not the ones in force on the day: Policies.