How I built a multi-tenant SaaS in Next.js 15 — and what I'd do differently
Six weeks, one founder, paying customers by week 7. Here's the architecture that worked, the migrations that didn't, and what I'd skip if I started today.
The brief was small enough to fit in a WhatsApp message. "RAG support assistant. Multi-tenant. Stripe billing. Six weeks." I quoted, the founder said yes, and we shipped.
Eight months later the product has paying customers, the founder is sleeping, and I have opinions. Here is the honest version — what I would build the same way again, and what I would torch.
The stack that survived
- Next.js 15 App Router, RSC, ISR for marketing pages.
- PostgreSQL with row-level security for tenant isolation.
- Drizzle ORM with Zod for runtime validation at the edge of the system.
- Auth.js with magic links plus Google.
- Stripe Checkout + Customer Portal. No custom checkout — never.
- Vercel for the front-end, Fly.io for the worker.
If I had to start over today I would not change one of these. Boring is correct when budget is tight.
What I'd do differently
1. Multi-tenant from line 1, not "we'll add it later"
I tried to ship single-tenant first to "save time". I cost myself a week. Multi-tenant is not just WHERE tenant_id = ? — it touches sessions, file uploads, background jobs, subdomains, and webhook payloads. Bake it in from the schema up.
// One middleware, one source of truth
export function middleware(req: NextRequest) {
const host = req.headers.get("host") ?? "";
const sub = host.split(".")[0];
if (PUBLIC_SUBS.has(sub)) return NextResponse.next();
const tenant = await getTenantBySubdomain(sub);
if (!tenant) return NextResponse.redirect(new URL("/", req.url));
const res = NextResponse.next();
res.headers.set("x-tenant-id", tenant.id);
return res;
}
2. Stripe webhooks: idempotency or pain
event.id as the primary key of a processed_webhooks table. Wrap the handler in a transaction. Skip if seen. This is one of two things I never compromise on (the other is timezone-aware timestamps in the database).
3. RBAC: don't roll your own enum
I started with a 3-value enum (owner, admin, member). The founder asked for "viewer" in week 4 and "billing-only" in week 5. Now I always start with a permissions table and assign role bundles. Two extra hours up front, two weeks saved later.
The mistake I'm still paying for
I picked pgvector for embeddings — that part is fine. But I stored embeddings in the same database as application data. When a customer ingested a 40MB PDF the daily snapshot ballooned and our recovery time objective went from 5 minutes to 40. Always separate the heavy column.
What it cost
- 6 weeks of my time
- $87/month in infra
- 2 hours of post-launch hand-holding for the founder
The lesson
Multi-tenant SaaS in Next.js 15 is not exotic anymore. The code is small. The database design is what matters. If you copy nothing else from this post, copy this: model your tenants in the schema before you write a single component.
If you are about to build something similar and want a second pair of eyes, send me a WhatsApp. I have shipped 12 of these now and the early-stage architecture call usually saves weeks.
Author
Usama
I have spent the last 6+ years shipping production websites and apps from Pakistan for clients across 30+ countries. I work daily on Fiverr and Upwork, and partner directly with founders, agencies and local businesses on long-term builds.
More about Usama