← All entries

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 of sunnyfield.<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. *.localhost works 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 barnSlug to the JWT token and to the SessionUser type.
  • Simplified authorize() — credentials no longer include a tenantSlug parameter; we look up which barn the user belongs to via barn_users after authentication and bake the slug into the token.
  • Middleware reads session.barnSlug first, then falls back to subdomain (so local dev still works at hgs.localhost), then to a BARN_SLUG env 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.