Multi-tenant Calendar SaaS
Lead Developer
A self-hosted booking platform: drop a single script tag on any business website and you have availability, calendar sync, and confirmations. Replacing Calendly-class SaaS for clinics, hairdressers, and service providers.
The system
Multi-tenant Calendar SaaS is a self-hosted booking system I built so clinics, hairdressers, and service providers can drop a single script tag on their website and have appointment booking, calendar sync, and confirmations without paying Calendly per user per month. The core is a Fastify 5 API on PostgreSQL 16 + Drizzle + Redis/BullMQ. Row-level multi-tenancy is enforced by a query wrapper that automatically scopes every database call by tenant_id, so one tenant cannot accidentally read another’s data because the database layer prevents it, not because the developer remembered. The admin SPA is React 19 + TanStack with Better Auth. The embed widget is vanilla JS, a single <script> tag drops it onto any third-party site. OAuth calendar sync (Google + Outlook) with DST-safe availability windows handles cross-timezone bookings. An async worker handles confirmation emails and hold expiry. 103 TypeScript files, 96 test files, deployed via Coolify.
Architecture
- API: Fastify 5 (Node 22), Postgres 16 + Drizzle ORM, Redis + BullMQ, Zod, date-fns/tz
- Widget: Vanilla JS bundle (drop-in
<script>tag on tenant customers’ sites) - Admin SPA: React 19 + TanStack Router/Query/Table, Better Auth
- Worker: Separate process for async jobs (confirmation emails, hold expiry, calendar sync)
- Multi-tenancy: Row-level with
tenant_idon all data tables, query wrapper enforcing isolation, prefixed nanoid IDs for human-readable logs (tenant_abc123) - Calendar sync: Google OAuth + Outlook OAuth per tenant provider, with DST-safe availability windows
- Per-tenant config: SMTP credentials, locale, timezone, branding
- Deployment: Coolify (webhook-triggered) with multi-stage Docker build
My contribution
I designed and built the system on my own. The architectural decision I am proudest of is the row-level multi-tenancy strategy: every database query passes through a wrapper that automatically injects WHERE tenant_id = ?, so the developer never has to remember to add it and a tenant data leak is structurally impossible. Prefixed nanoid IDs (tenant_abc123, booking_xyz789) give support engineers human-readable logs without UUID ambiguity. I wrote the vanilla JS embed widget from scratch because tenants put it on third-party sites where I cannot assume React or Vue is available. OAuth calendar sync (Google + Outlook) was implemented per-provider with DST-safe availability windows via date-fns/tz, and a BullMQ worker handles confirmation emails and sync jobs without blocking the HTTP request path. The 96 test files split into unit, integration, e2e, and admin Playwright tests for a fast-feedback CI.
Stack details
The 96 test files split into unit (isolated logic), integration (DB + Redis), e2e (full API flows with Vitest), and admin (Playwright UI tests). This split allows a fast feedback loop: unit tests in milliseconds, integration tests with a real DB (Testcontainers), e2e last. The query wrapper for tenant isolation is a Drizzle middleware that wraps every db.select() / db.insert() / db.update() / db.delete(), only explicit super-admin queries are excluded. Prefixed nanoid IDs solve a practical problem: when support reads logs, booking_7kRt3m is immediately recognisable as a booking ID. DST-safe availability: each provider stores a timezone string (Europe/Athens), and slot calculations use date-fns/tz for accurate DST transitions, avoiding the classic double-booking bug at clock changes.
Outcomes
- Production multi-tenant booking system: ready to onboard real customers
- Extensive test coverage (96 files) building confidence for future changes
- Documented architecture (
docs/superpowers/specs/) reducing onboarding time for future contributors - Production deployment via Coolify with webhook deploys
The challenge
The hardest part was the DST-safe availability logic with cross-tenant timezones. Each provider has their own timezone. A guest in Europe/London books an appointment with a provider in Europe/Athens. If a clock change falls between booking creation and the appointment, the slot can appear wrong to one of the two. The solution: I store all slots in UTC, but display and validation logic uses date-fns/tz with the provider’s timezone, not the guest’s timezone. Availability window calculation (which hours are available) always happens in the provider timezone, converts to UTC for storage, and converts back for display. The test suite for this includes explicit DST transition dates for Europe/Athens and Europe/London to cover edge cases.
Links
- Repo:
jimrarras/multi-tenant-calendar(private)
Related work
- TaskFlow: another multi-tenant SaaS with real-time and offline-first, but for task dispatch rather than bookings
- Heartbeat Pharmacy PIM: another production system with Better Auth and Coolify deployment
Gallery