Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.
Vertical SaaS that gives a salon, barbershop or solo beauty specialist their own booking website in minutes. Services pre-filled for 13 business types, four designs, 32 languages, one monthly subscription and zero commission on bookings.
Key Results
Small beauty businesses – a two-chair nail studio, a solo brow artist, a barbershop with three people – still take most of their bookings in Instagram DMs and WhatsApp. ‘Anything free tomorrow at 10?’, ‘How much is a lash lift?’, ‘Can I move to 5pm?’ – every one of those is answered by hand, often between clients or late at night.
The existing options don’t fit this owner well. Marketplaces take a commission on every booking and put the salon’s page next to its competitors. Website builders give a blank canvas and expect the owner to design, write and wire up booking themselves. Enterprise salon software is priced and built for chains.
KRI is a vertical SaaS I designed and built end to end: product, architecture, backend, frontend, billing and infrastructure. An owner picks a business type, enters a name and city, checks a pre-filled list of services and prices, and adds their team with working hours. At the end of those four steps the salon has its own booking website at a personal kri.rocks address.
The product is opinionated on purpose. Instead of a builder, the owner chooses one of 13 business types – beauty salon, hair salon, barbershop, nail studio, lashes and brows, massage, spa, tattoo and more – and one of four designs (Minimal, Modern, Classic, Soft). Typical services arrive filled in, so the first thing an owner does is adjust prices, not build pages.
Clients book around the clock and see only real availability: each specialist has their own hours and days off, and the client can choose who they book with. A confirmation goes out on booking, a reminder before the visit and a notice if it is cancelled – each in the language the client booked in. The owner runs everything from one dashboard – today’s bookings, the calendar, clients with visit history, services, team, the website itself and billing – designed for the phone between appointments first.
Pricing is one monthly subscription that grows only with the number of specialists taking bookings, with no commission and no setup fee. The price depends on the country: every country selectable at sign-up has its national currency and either its own price or a floor price, and the whole table lives in one file that is validated on every type-check – a price below the floor, a missing currency or an unknown field fails the type-check instead of reaching a customer.
| Metric | Value |
|---|---|
| Architecture | Next.js 16 web app + separate Hono API, deployed as two services |
| Codebase | ~53k lines of TypeScript in a Turborepo monorepo |
| Languages | 32, per-audience address register checked by a locale linter |
| Business types / designs | 13 / 4 |
| Countries at sign-up | 226, each with its currency and price validated on type-check |
| Sign-in | Passwordless – magic link plus a 6-digit code |
| Commission on bookings | 0% |
| Double-booking protection | Per-specialist advisory lock + overlap check in one transaction |
| Double-charge protection | Partial unique index: at most one live payment per invoice |
For the technically curious, here is how the core pieces are built.
The web app and the API are separate applications and deploy as separate containers – a deliberate decision so business logic can never leak into Next.js route handlers. The web app resolves every request by host into a zone: the marketing site, the app subdomain with the dashboard and onboarding, or a salon’s public page, which is rewritten to an internal route with the tenant slug attached. Public tenant lookups read a Redis cache first and fall back to PostgreSQL, so a salon’s page doesn’t cost a database round-trip on every visit. Authenticated calls go through the web app’s own origin, so the auth cookie stays host-only.
A booking is created in one transaction that takes a Postgres advisory lock scoped to the specialist, re-reads their availability and checks for an overlapping booking before inserting. Two clients racing for the same slot are serialized; the second one gets a clean ‘this time was just taken’, never a duplicate:
const booking = await db.transaction(async (tx) => {
// Serialize concurrent bookings for the same specialist: availability check,
// overlap check and insert are atomic against another booking for them.
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${"booking:" + masterId}))`);
const [live] = await tx
.select({ isActive: masters.isActive, lastWorkingDay: masters.lastWorkingDay })
.from(masters)
.where(eq(masters.id, masterId))
.limit(1);
if (!live || !isMasterBookableOn(live, localDate)) {
throw new BookingRejected(409, "not_working", "This time is not available");
}
// ...check the requested time falls inside the specialist's working hours
const [conflict] = await tx
.select({ id: bookings.id })
.from(bookings)
.where(
and(
eq(bookings.masterId, masterId),
lt(bookings.startsAt, endsAt),
gt(bookings.endsAt, startsAt),
LIVE_BOOKING
)
)
.limit(1);
if (conflict) throw new BookingRejected(409, "slot_taken", "This time was just taken");
// ...insert the booking
});There is deliberately no unique constraint on (specialist, start time): an owner may put two clients on one specialist from the dashboard. Only client-facing booking is serialized. All times are stored in UTC and shown in the salon’s time zone, and slot generation skips wall-clock times that a daylight-saving change removes.
Subscription billing runs on Mollie, but without Mollie’s Subscriptions API: the amount changes every period with the number of specialists and the VAT treatment, so KRI stores the card mandate and issues each charge itself. The invariants that money depends on are enforced by PostgreSQL, not by code remembering to check:
// The single most important guard against a double charge.
uniqueIndex("payment_attempts_one_live_idx")
.on(t.documentId)
.where(sql`${t.state} IN ('created', 'pending', 'succeeded', 'review')`),A second partial unique index allows only one live invoice per tenant and period. Issuing an invoice, charging it, cancelling a subscription and detaching a card all take the same per-tenant advisory lock around their ‘is anything owed?’ check, so a cancellation and a renewal can never each act on facts the other has just changed. Race cases like these are covered by tests that run against a real PostgreSQL with a controlled interleaving.
Every invoice and credit note is a numbered, frozen snapshot rendered to PDF with pdf-lib. EU business customers’ VAT numbers are checked against VIES through the vatnode API, with three outcomes kept apart – valid, invalid and ‘register unreachable’ – because treating an outage as invalid charges VAT wrongly and treating it as valid drops VAT that is owed. Provider notifications are treated as hints: the handler reads the real state back from Mollie, and every write is conditional on the state it expects, so a notification delivered twice changes nothing.
Translations are plain JSON per locale, and a language is available as soon as its folder is complete – adding one means adding files, not code. A locale linter runs with the regular lint and checks key order against English, reports key drift between locales for a human decision, and enforces terminology and address register per language. Register is a property of the audience, not the language: KRI writes to the salon owner and, on the owner’s behalf, to the salon’s clients. German, for example, addresses the owner as du and the client as Sie, so the linter maps each namespace to its audience and checks the right form in each.
AvailableNeed something similar?
I build custom solutions – from APIs to full products. Let’s talk about your project.
Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.
SaaS platform for PDF authenticity verification with a public REST API.
Libraries
Databases
Services
Turborepo monorepo with Next.js and Hono, sharing one set of TypeScript types across frontend and backend.
Libraries
Databases
Services
Topics
40+ point production SaaS checklist: auth, Stripe billing, PostgreSQL, rate limiting, email, monitoring, and security – with honest 8-week build estimates.
Stack
Databases
Topics