Engineering at Bookatu

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.

Engineering

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.

10 min read
Engineering

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.

10 min read
Engineering

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.

10 min read
Engineering

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.

7 min read
Engineering

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.

7 min read
Engineering

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.

7 min read
Engineering

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.

7 min read
Engineering

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.

8 min read
Engineering

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.

7 min read
Engineering

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.

7 min read
Frontend

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.

7 min read
Engineering

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.

7 min read
Engineering

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.

6 min read
Engineering

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.

4 min read
Engineering

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.

6 min read
Engineering

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.

5 min read
Engineering

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.

6 min read
Engineering

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.

5 min read
Engineering

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.

6 min read
Engineering

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.

5 min read
Engineering

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.

7 min read
Engineering

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.

7 min read
Payments

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.

7 min read
Engineering

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.

5 min read
Architecture

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.

7 min read
Engineering

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.

6 min read
Internationalization

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.

7 min read
Engineering

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.

7 min read
Frontend

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.

7 min read
Reliability

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.

7 min read
Architecture

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.

7 min read
Infrastructure

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.

7 min read
Architecture

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.

6 min read
Reliability

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.

7 min read
Internationalization

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.

7 min read