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
RequestandResponsethat 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 signupgives you ask_test_key, andnpx bookrail init --template padelfollowed bynpx bookrail pushcreates a service in about a minute: the Quickstart walks through both. Thesvc_...id is in the output ofnpx 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.
1. Install the SDK and set the key
Section titled “1. Install the SDK and set the key”npm install @bookrail/nodeBOOKRAIL_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.
2. One client, on the server only
Section titled “2. One client, on the server only”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.
3. Two route handlers
Section titled “3. Two route handlers”The browser never talks to Bookrail. It talks to these two handlers, which hold the key.
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.
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.
4. A minimal form
Section titled “4. A minimal form”'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> );}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.
5. Errors, and what fix is for
Section titled “5. Errors, and what fix is for”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:
| Code | Status | What the form says |
|---|---|---|
slot_unavailable | 409 | The time was taken in the meantime: pick another. |
parameter_invalid | 400 | A field was malformed, such as a date without an offset. param names it. |
plan_limit_reached | 402 | A live key on the Free plan used this month’s bookings (Pricing). |
rate_limited | 429 | Too 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.
6. The webhook
Section titled “6. The webhook”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:
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:
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.
Going live
Section titled “Going live”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.
What this page leaves out
Section titled “What this page leaves out”- 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 thenhold_idon 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.