How we build Bookatu
Honest notes from the team behind a 0%-commission booking platform: payments, multi-tenancy, internationalization, the booking engine and the calendar. Real decisions and the trade-offs behind them.
Six tables, one booking a night
Our availability engine grew up in a salon, where a busy stylist means no appointment however many chairs are free. A restaurant is the opposite shape, and one line that had been in the file since its first version meant a dining room with six tables went dark the moment the first party sat down. The fix was small. The two layers underneath it were not.
The optional parameter that was four hours wrong
We booked the noon slot at a test shop in Perth and the confirmation email said 4:00pm. Nothing threw, nothing logged, and the email was well formatted and wrong. The cause was a question mark in a function signature, and the part worth reading is the shape of the fix rather than the bug.
Green for the wrong reason
We put a bug back into the code to find out which of the tests written to catch it would go red. Three different ways of breaking it, and the suite stayed green through all three. This is what was wrong with those tests, and the cheap loop that came out of it, which then turned up a rule nothing in our suite has ever checked.
The link in the email was never going to work
A feature request sent us to read our own invoicing code, and the Pay now button in every invoice we had ever emailed pointed at an address that does not exist. Here is the bug, the worse one hiding behind it, and why the fix was a route rather than a string.
Propose then confirm: a safe pattern for AI that writes to your database
Owners can run parts of Bookatu by chat, and every one of those requests is a write to a live database that other businesses share. We never gave the model write access. It proposes a structured change, we validate it like any form post, and a person confirms before anything is saved.
Undo for AI actions: recording every change with its reverse
A confirm dialog stops most mistakes with an AI assistant, not all of them. So next to the changes ours makes there is an Undo, and it is not a history feature bolted on afterwards. Each reversible change records its own reverse at the moment it happens, so it can be taken back in one tap.
The allowlist is the boundary: keeping an AI assistant on rails
The most useful question to ask about an AI assistant is not what it can do, it is what it cannot. Ours can call a fixed, hand-picked set of tools and nothing else. Here is why that list is an allowlist rather than a denylist, why it lives in code rather than in a prompt, and what we keep off it on principle.
React 19 quietly resets your form, and when that bites
Our intake form posts through a server action. During testing, one rejected field came back to a page where everything the client had typed was gone, including a signature they had drawn with a finger. React 19 resets forms after an action on purpose, and the reasoning is sound. Here is why the reset exists, where it hurts, and the small submit pattern that keeps typed input on the page.
One app, two theme planes: where color-scheme leaks
Our platform shell has its own light and dark theme, and every storefront has an independent one. The custom property tokens were scoped correctly, yet native checkboxes still flipped with the wrong theme. The culprit was color-scheme, a property that inherits from the root like any other, and it bit us a second time across an iframe boundary.
The signature pad that would not draw
Our consent forms end with a client drawing their signature on a canvas. In testing it accepted every touch and produced no ink. The cause was a canvas that measured itself while hidden inside a later step of a multi-step form. Here is what a zero-sized canvas actually does, how to size one properly for phone screens, and the re-setup pattern that keeps the ink and never posts a stale signature.
Packing a day: laying out overlapping bookings without a calendar library
Our day-view preview drew every booking at full width, so overlapping appointments rendered as text over text. The fix was the classic interval packing pass: assign each booking the lowest column whose previous booking has ended, then size each overlap cluster by its own peak concurrency. Extracting it as a pure function is what finally made it testable.
Keeping AI free: a fair-use quota that never blocks the work
We wanted to use a language model across the product to make imports more accurate, without putting AI behind an upgrade and without a surprise bill. The trick was to treat AI as a boost on top of a result that is already correct, meter it per tenant with no new tables, and let it fall back the instant an allowance runs out.
Storefront themes that can't break a layout, and the CSS layer that fought us
Letting every business pick a look for their shop page sounds like a support nightmare waiting to happen. Here's how we made themes that restyle a storefront but are structurally incapable of breaking it, and the @layer gotcha that ate an afternoon.
The focus bug hiding in a disabled input
A search box that kicked the cursor out after every couple of keystrokes. The cause was one prop doing exactly what it was told, and a browser rule most of us half-remember. A short story about a sharp little bug.
Idempotent webhooks: making sure the money only moves once
Payment providers promise to deliver a webhook at least once, which is a polite way of saying sometimes twice. If a repeat delivery confirmed the booking a second time, awarded loyalty twice and emailed two receipts, our customers would notice fast. Here is the small idea that makes every downstream effect happen exactly once, no matter how many times the same event lands.
Settings as a directory, not a wall of tabs
A booking business has a lot to configure, and for a while our settings screen showed all of it at once behind a row of tabs. It worked, but it read as a wall. We rebuilt it as a plain directory of rows that opens one focused section at a time. Same fields, same save actions, a fraction of the cognitive load.
Generous storage that still makes money: a hard cap, real-byte metering, and pack overage
A photographer's plan now includes up to a terabyte of gallery storage. Here is how we made the included space generous without turning it into a loss: enforce the cap when photos go in, trust the size the store reports rather than the one the browser claims, and sell the overflow as simple fixed packs.
Reading the visitor's language: Accept-Language, q-values, and refining by country
Our marketing site serves regional markets like en-NZ and de-DE, and the bare URL has to redirect each visitor to the right one. We used to do that by IP country alone, which sent a German tourist in New York to an English page. Now we read the browser's own language preference first, refine it by where they are, and we moved the language dropdown to the footer because the page usually guesses right.
Protecting photographers' images on the web: what actually helps
Right-click blockers and CSS overlays are theatre. Here is the pipeline we use in the Bookatu photographer galleries: baked-in pixel watermarks via canvas, a hard downscale to 1080px, EXIF stripping, and serving the full-resolution file only through an ownership-checked route.
The refund we never meant to give: closing a free-download loophole in gallery proofing
A photo client could trade a delivered photo back and forth in a way that settled to free. The fix was one identifier, and a clear lesson about modelling state by its lifetime.
Magic links done right: why a login token is not a share link
Two things in Bookatu look identical from a browser address bar: the passwordless login link and the gallery share link. They must never be the same token. Here is how we sign, scope, and expire them, with a sign and verify sketch.
One dashboard, seven industries: a config-driven, vertical-aware admin
Bookatu runs salons, spas, trainers, coaches, studios, restaurants and photographers from one admin. Here is how a single config map, plus named constants instead of hardcoded industry strings, keeps that admin clean as the list of verticals grows.
How 0% commission works: Stripe Connect and money that never touches us
A walk through how Bookatu takes a client deposit straight into the salon's own Stripe balance with a direct charge on a connected account, so the platform never holds the money.
Two planes and a gotcha: per-tenant theming with CSS custom properties
How Bookatu themes every tenant's storefront from one component tree: brand colour injected as CSS variables, dark mode built as a second value plane, and the inline-specificity bug that needs exactly one !important.
One codebase, thousands of branded sites: our multi-tenant model
How Bookatu serves every tenant's branded storefront and back office from one Next.js app: a slug in the path, a flag-gated host proxy, and org_id on every query.
A +25GB storage pack is just a quantity line: billing add-ons as Stripe subscription items
How Bookatu turns a photographer's "+25GB storage pack" into a quantity line on the org's existing platform subscription, finds or creates a versioned price, prorates the change, and keeps the in-app storage cap from drifting away from what Stripe actually bills.
Localizing into 13 languages without /[locale] routes
How Bookatu ships its UI in 14 languages with no /[locale] URL segment: a pure deep-merge catalog, a visitor-locale resolver over header, cookie and geo, and SEO bolted on later behind a flag.
Keeping a client photo gallery private: row scoping, a private bucket, and a route that 404s on any mismatch
How Bookatu keeps each photographer's client galleries private in a multi-tenant app: org plus customer row scoping in Postgres with Drizzle, a private blob store, and a serving route that checks ownership and returns 404 for anything you do not own.
Building a touch-friendly booking calendar from scratch
Why we built Bookatu's drag-to-reschedule calendar on raw pointer events instead of a library, and how the touch model, swim lanes and optimistic updates actually work.
Stopping fake signups without punishing real owners
How Bookatu keeps junk businesses off a public booking platform with layered checks that fail open, so a DNS hiccup or a Google outage never blocks a real salon owner.
Recurring appointments and the 'admin force-book' rule
How Bookatu books a standing recurring series through the same pipeline as one-off bookings, why admin scheduling force-books while customer booking stays strict, and the one collision the database still refuses.
Custom domains for every tenant with Vercel for Platforms
How Bookatu lets each tenant point their own domain at their storefront: a flag-gated routing proxy, the Vercel Domains API behind a wrapper that never throws, and a TTL cache that keeps the common request free.
Pricing by active clients: computing who actually counts
How Bookatu bills by active clients instead of seats: a 90-day window, a deliberately plain in-memory de-dupe across two activity sources, and a clean split between lifecycle and counting.
Testing a booking engine with pglite and 1600+ tests
How Bookatu tests its booking engine against a real in-memory Postgres: actual migrations, partial unique indexes that catch double-bookings, and 1600+ tests that run anywhere.
Per-language URLs for SEO via a proxy rewrite
Cookie-based localisation is invisible to Googlebot, so it only ever indexed our English pages. Here's how we made 14 languages crawlable with a flag-gated proxy rewrite and zero new routes.