Monday, April 27, 2026
Day 25 — single URL, JWT-routed tenancy
Killed the subdomain-per-barn architecture I'd been carrying since Day 1. Tenant now resolves from the JWT session, not the URL. Simpler for users, simpler for me, fewer support tickets in our future.
2 min read · Backfilled retrospective — written 2026-05-04
Daily entry
Day 25of building EquinePilot
Currently Day 77 · founder build log
Milestones reached
- ✓Session-based tenancy
A piece of architecture I'd been carrying since Day 1 went out today.
The original plan was that each barn lived at its own subdomain — Sunnyfield Equestrian would log in at sunnyfield.<our-app> and that subdomain would be the source of truth for which tenant they belonged to. Multi-tenancy in the URL is a clean separation; it's also what most B2B SaaS does at this stage. But sitting with it for a few weeks I started to count the ways it was going to be a paper cut at scale:
- Wrong-subdomain login attempts. Manager goes to
othername.<app>instead ofsunnyfield.<app>, types correct credentials, gets rejected, files a ticket. - Email link generation. Every welcome email, every password reset, every QBO redirect URI has to know what subdomain the recipient belongs to. If you generate the wrong host, the link breaks silently.
- Local dev.
*.localhostworks but adds a step to onboarding. - Tenant changes. If a barn ever needs to be renamed, every existing email link to the old subdomain is broken.
So today I migrated to session-based tenancy. One URL for everyone. Tenant comes from the JWT session. The shape:
- Added
barnSlugto the JWT token and to theSessionUsertype. - Simplified
authorize()— credentials no longer include atenantSlugparameter; we look up which barn the user belongs to viabarn_usersafter authentication and bake the slug into the token. - Middleware reads
session.barnSlugfirst, then falls back to subdomain (so local dev still works athgs.localhost), then to aBARN_SLUGenv var (for Vercel preview deploys without subdomain routing). getTenant()/getTenantSlug()server-side helpers read the value from the request header that middleware injects.
What changed in practice: a manager opens our app at the root domain, signs in, lands on their barn's manager dashboard. No subdomain to remember. If they forget a credential, the password reset email points at the root domain, not at theirbarn.<app>.
The schema-per-tenant model is unchanged — every barn still has its own Postgres schema, full structural data isolation. Only the routing layer changed.
This is the kind of architectural decision that's easy to get wrong early and expensive to undo later. Caught it at the right time. Two weeks earlier and I'd have had less code to migrate; two weeks later and I'd have had email templates and external integrations baked into the old shape. Today felt right.