How to Add Booking to a Next.js App With a REST API (2026)
Originally published on opencals.com. The original stays updated; check it for the latest version. Adding a booking page to a Next.js app looks like a weekend project until you start it. The UI is the easy part. The har
Originally published on opencals.com. The original stays updated; check it for the latest version.
Adding a booking page to a Next.js app looks like a weekend project until you start it. The UI is the easy part. The hard part is everything the calendar needs to know before it can show a single open slot: which staff are working, how long the service takes, what's already booked, how much buffer to leave between appointments, and whether two people can hold the same slot at once. That logic is where booking projects quietly turn into month-long ones.
This guide takes the other path. Instead of building the scheduling engine yourself, you call a booking API for the availability, cart, and payment logic, and you keep full control of the Next.js frontend. I built Opencals as exactly that kind of API, so the code here uses it — but the shape of the problem is the same whichever backend you pick, and I'll flag the parts that are universal.
Key takeaway
The frontend of a booking flow is a few forms and a calendar grid. The backend is real-time availability across staff, services, and existing bookings — plus payments. Calling a booking API lets you own the first part and hand off the second.
What "add booking" actually involves
Before any code, it helps to name the pieces. A working booking flow has four moving parts, and most half-finished projects stall because they underestimated one of them.
1. A service catalog
What can be booked — a 60-minute session, a color treatment, a group class — each with its own duration, price, and the staff who can deliver it. This is your booking homepage data.
2. Real-time availability
Given a service and a date range, which slots are actually open? This has to account for existing bookings, staff hours, buffer time, and multi-staff conflicts. It is the single hardest piece to build well.
3. A cart and checkout
Holding a slot while the customer enters details and pays, then confirming the booking only if payment succeeds. Slots are a scarce resource, so this needs to be race-safe.
4. Post-booking management
Confirmation, reminders, and letting customers reschedule or cancel without emailing you. Easy to skip on day one, painful to add later.
If you build all four yourself, you're writing a scheduling engine. If you call an API, you're wiring four typed methods into React components. This guide does the second.
Prerequisites
You need a Next.js 14+ app using the App Router, Node 18+, and a booking API key. With Opencals that's a private storefront key (prefixed spk_), created in the dashboard under Users → your user → API Keys; any booking API will have an equivalent. Install the SDK:
npm i @opencals/storefront-sdk
Then set the key in your environment. Keep it server-side — a private storefront key can create real bookings, so it does not belong in client bundles.
# .env.local
OPENCALS_API_KEY=spk_your_key_here
Why server-side
Anything that creates a booking or a payment should run in a Route Handler or Server Action, never in a client component. The browser calls your Next.js API routes; your routes call the booking API with the secret key. This is the same pattern you'd use for a Stripe secret key.
Step 1 — Initialise the SDK once
Create a small module that configures the client. setupOpencals sets up the SDK's shared client, so you call it once and every service call after that uses the key. Import this module at the top of any server file that calls the API.
// lib/opencals.ts — import from server code only
import { setupOpencals } from "@opencals/storefront-sdk";
import { nextBuyerIp } from "@opencals/storefront-sdk/next";
setupOpencals({
apiKey: process.env.OPENCALS_API_KEY, // spk_…
buyerIp: nextBuyerIp,
throwOnError: true, // failed calls throw OpencalsApiError instead of returning { error }
});
nextBuyerIp forwards each visitor's IP on sign-in, cart and checkout calls, so the API rate-limits per visitor instead of treating your server as one very busy customer. It skips GET requests on purpose, so your cached pages stay static.
If you prefer raw fetch over an SDK, the same call is a GET with the key in an X-Api-Key header — the SDK just gives you typed responses. Both work; typing saves you from guessing the shape of a nested availability object at 11pm.
curl https://api.opencals.com/storefront/products \
-H "X-Api-Key: spk_your_key"
Step 2 — List services on a Server Component
The App Router lets you fetch on the server and stream HTML, which is ideal for a service catalog — it's public data, good for SEO, and doesn't need to be interactive.
// app/book/page.tsx
import "@/lib/opencals";
import { ProductService } from "@opencals/storefront-sdk";
import { ServiceCard } from "./service-card";
export default async function BookPage() {
const { data: page } = await ProductService.list({
query: { status: ["active"], take: 50 },
throwOnError: true,
});
return (
<main className="grid gap-4">
{page.data.map((service) => (
<ServiceCard key={service.id} service={service} />
))}
</main>
);
}
The list comes back as { data, meta }, fully typed. Each service carries its price (in minor units), duration (seconds), beforeGap/afterGap buffers and a variants array, each variant with its own price and duration. formatPrice and formatDuration from the SDK turn those into display strings. You render, you don't compute.
Step 3 — Fetch real-time availability
This is the endpoint that would have taken you weeks to write. You pass a service and a date, optionally a staff member or location, and you get back the slots that are open right now.
// app/api/availability/route.ts (Route Handler)
import { NextRequest, NextResponse } from "next/server";
import "@/lib/opencals";
import { ProductService } from "@opencals/storefront-sdk";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const { data: slots } = await ProductService.getCurrentAvailabilities({
path: { productId: searchParams.get("productId")! },
query: {
date: searchParams.get("date")!, // YYYY-MM-DD
staffMemberId: searchParams.get("staffMemberId") ?? undefined,
timezone: "Europe/Warsaw", // your store's timezone
},
});
return NextResponse.json({ slots });
}
Each slot is { fromDate, fromTime, toDate, toTime, staffMemberIds, attendees, maxAttendees }, and the list already leaves out times that are booked, outside staff hours, or too close to another appointment given the service's buffers. Your calendar component fetches this route when the user picks a date and renders whatever comes back — no client-side conflict math. For a month view, getCurrentAvailabilitiesMerged returns the broad open windows so you can grey out full days.
The universal part
Every booking backend worth using exposes an availability endpoint like this. If a tool only gives you raw calendar events and expects you to compute open slots yourself, you haven't offloaded the hard part — you've just moved it. Availability calculation is the feature to evaluate first.
Step 4 — Hold the slot with a cart
A slot is a scarce resource. Between "customer clicked 2pm" and "customer paid," you don't want someone else grabbing it, and you don't want to create a real booking that might never be paid for. A cart holds the slot for a short window: you create (or reuse) a cart, then create the appointment inside it.
// app/api/cart/route.ts
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import "@/lib/opencals";
import { AppointmentService, CartService } from "@opencals/storefront-sdk";
export async function POST(req: NextRequest) {
const { slot } = await req.json(); // one slot from step 3
const jar = await cookies();
const { data: cart } = await CartService.createOrGet({
headers: { "X-Cart-Id": jar.get("cartId")?.value ?? "" },
});
await AppointmentService.create({
body: {
slot: {
productId: slot.productId,
fromDate: slot.fromDate,
fromTime: slot.fromTime,
toDate: slot.toDate,
toTime: slot.toTime,
},
numberOfAttendees: 1,
cartId: cart.id,
},
});
jar.set("cartId", cart.id, { httpOnly: true, sameSite: "lax" });
return NextResponse.json({ cartId: cart.id });
}
The cart ID lives in an httpOnly cookie, and every cart and checkout call names its cart with the X-Cart-Id header. Times are UTC. If two people try to hold the same slot at the same moment, one succeeds and the other gets a clear error, so your UI can say "someone just took that time" instead of double-booking.
Step 5 — Check out and pay
Checkout turns a held cart into a paid booking in two server calls with the card confirmation in between. start attaches the customer, re-checks the cart and creates the payment; the browser confirms the card with Stripe; submit places the order.
// app/api/checkout/start/route.ts
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import "@/lib/opencals";
import { CheckoutService } from "@opencals/storefront-sdk";
export async function POST(req: NextRequest) {
const { email, firstName, lastName } = await req.json();
const cartId = (await cookies()).get("cartId")!.value;
const { data: checkout } = await CheckoutService.start({
headers: { "X-Cart-Id": cartId },
body: { provider: "stripe", customer: { kind: "new", email, firstName, lastName } },
});
// The browser needs these to mount a Stripe Payment Element.
return NextResponse.json({
clientSecret: checkout.clientSecret,
publishableKey: checkout.publishableKey,
stripeAccountId: checkout.stripeAccountId,
});
}
// app/api/checkout/submit/route.ts — called after stripe.confirmPayment() succeeds in the browser
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import "@/lib/opencals";
import { CheckoutService } from "@opencals/storefront-sdk";
export async function POST(req: NextRequest) {
const { paymentIntentId } = await req.json();
const cartId = (await cookies()).get("cartId")!.value;
const { data: result } = await CheckoutService.submit({
headers: { "X-Cart-Id": cartId },
body: { stripePaymentIntentId: paymentIntentId },
});
return NextResponse.json(result);
}
The Stripe publishable key comes back from start, from the store's own Stripe connection, so there is no Stripe key to configure in your app. If the card wasn't authorised (declined, 3-D Secure not finished), submit answers 402 with the code PAYMENT_NOT_AUTHORIZED, no order is placed and the cart is untouched, so the customer can try again. A second start or submit on the same cart while one is running gets CHECKOUT_IN_PROGRESS instead of a duplicate order. The slot only becomes a real booking once the payment is authorised.
Step 6 — Let customers manage their own bookings
The reschedule-and-cancel flow is the one teams skip first and regret most, because without it every change becomes a support message. These calls run as the signed-in customer: AuthService.signIn returns an accessToken, and you send it as a Bearer token next to your store key, so the customer only sees and touches their own bookings.
// server code, with the customer's accessToken from AuthService.signIn
import "@/lib/opencals";
import { AppointmentService } from "@opencals/storefront-sdk";
const asCustomer = { Authorization: `Bearer ${accessToken}` };
const { data: upcoming } = await AppointmentService.list({
headers: asCustomer,
query: { status: ["scheduled", "pending"], orderBy: "from", order: "ASC" },
});
await AppointmentService.reschedule({
headers: asCustomer,
path: { appointmentId },
body: { slot: newSlot }, // a slot from getCurrentAvailabilities, fetched with excludeAppointmentId
});
Pass excludeAppointmentId when you fetch availability for a reschedule, so the appointment's own slot isn't shown as taken. The store's reschedule and cancel windows still apply. Wire these into an account page and the reschedule requests that used to hit your inbox now resolve themselves.
Putting it together
The full flow, from the frontend's point of view, is six typed calls in five steps:
1. List services
ProductService.list() → render the catalog on a Server Component.
2. Check availability
ProductService.getCurrentAvailabilities() → the open slots for a date, computed server-side.
3. Hold the slot
CartService.createOrGet() + AppointmentService.create() → the chosen slot is held in a cart while the customer checks out.
4. Start checkout
CheckoutService.start() → the customer is attached and a payment is created for the browser to confirm.
5. Place the order
CheckoutService.submit() → the held slot becomes a real, paid booking.
Everything hard about scheduling — the availability math, the race conditions, the payment-to-booking handoff — lives behind those calls. Your Next.js app stays a frontend: forms, a calendar grid, and route handlers that proxy to the API with your secret key.
When not to use an API for this
Honest caveat, because not every project needs this. If you're a solo consultant booking 1:1 calls and you don't take payment up front, a hosted tool like Calendly is simpler and you shouldn't build anything. An API earns its keep when you need a custom-branded flow, multiple staff or locations, payments at booking, or booking embedded inside a product you already own. That's when owning the frontend while renting the backend is the right trade — and the links below cover the endpoint surface, the architecture, and the typed SDK.
Where to go from here
- Booking API guide: The endpoint-level reference: auth model, core routes, and the full booking flow in raw REST.
- Booking SDK: The typed TypeScript SDK used in this guide — services, availability, cart, and checkout end to end.
- Headless booking system: The architecture behind building a custom booking frontend on any stack.
- Opencals for developers: API docs, the OpenAPI spec, and how to get a storefront key.
- Build a booking website: Three approaches compared, from no-code to a fully custom build.
Frequently asked questions
Can I add booking to Next.js without a third-party API?
Yes, but you're then building a scheduling engine: real-time availability across staff and services, buffer logic, race-safe slot holding, and payment-to-booking handoff. For a single service with no payments that's doable in a weekend. For anything with multiple staff, locations, or paid bookings, the availability and concurrency logic is where most of the effort goes — which is what an API removes.
Where do I keep the booking API key in Next.js?
Server-side only — in an environment variable read by Route Handlers or Server Actions. A storefront key can create real bookings and charges, so it must never ship in a client bundle. The browser calls your own Next.js API routes; those routes call the booking API with the secret key.
Does this work with the App Router and Server Components?
Yes. Public reads like the service catalog fit Server Components and stream as HTML, which is good for SEO. Anything that mutates — cart, checkout, reschedule — goes in Route Handlers or Server Actions so the secret key stays on the server.
How is a booking API different from a calendar API like Google Calendar?
A calendar API stores and returns events. A booking API computes availability — it knows service durations, staff assignments, buffers, and existing bookings, and returns the slots a customer can actually book. With a calendar API you'd still have to write all of that logic yourself on top of raw events.
Can customers reschedule or cancel on their own?
Yes. The customer signs in through the API (AuthService.signIn returns an access token), and your server calls AppointmentService.list, reschedule and cancel with that token as a Bearer header next to the store key, so each customer only sees their own bookings. The store's reschedule and cancel windows still apply. Wire them into an account page and the change requests you'd otherwise answer by email resolve themselves.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.