How We Built a Multi-Tenant SaaS in TypeScript from Scratch
A pragmatic, code-first walkthrough of designing a multi-tenant SaaS architecture in TypeScript, covering tenant isolation, auth, billing, and the patterns we wish we'd known earlier.
DocPilot Team
AI documentation research and engineering from Onzira Labs.
Most "build a SaaS" tutorials stop at a TODO list app and skip the hard parts. This one doesn't. Over the past year we built, shipped, and scaled a multi-tenant SaaS platform in TypeScript, and the architecture decisions we made in week one still shape the codebase today. Here's the real story: what we picked, what we tried, what broke, and what we'd do differently.
The Core Constraints We Designed Around
A multi-tenant SaaS serves many customers from a single deployment. That single sentence hides ten hard problems: data isolation, auth, billing, observability, onboarding, customization, regional latency, compliance, cost, and developer velocity. You can't optimize all of them at once, so you pick the three that matter most.
For us, those were:
- Tenant isolation. Customers must not be able to see each other's data, ever. Period.
- Iterability. We needed to ship new features weekly without migration nightmares.
- Operational simplicity. Two engineers maintaining the platform. No Kubernetes, no dedicated DBA.
That last constraint ruled out fancier patterns like database-per-tenant from day one. We went with the simplest design that could survive real customers.
The Stack
Everything runs on TypeScript. The runtime is Node.js 20 with the LTS line, the HTTP layer is Fastify (chosen over Express for its async-first request lifecycle and built-in JSON schema validation), and the ORM is Prisma for most tables and raw SQL for the few hot paths where Prisma's overhead was measurable.
The database is Postgres on RDS. We use Redis for sessions and rate limiting. Auth is JWT with rotating refresh tokens stored in Postgres, plus a thin session table for revocation. Background jobs run on BullMQ. Frontend is Next.js, but most of the lessons in this post apply regardless of the client.
// packages/api/src/server.ts
import Fastify from 'fastify';
import { tenantContextPlugin } from './plugins/tenant-context';
import { authPlugin } from './plugins/auth';
const app = Fastify({ logger: true });
app.register(authPlugin);
app.register(tenantContextPlugin); // resolves tenant before every request
app.register(require('./routes/projects'));
app.register(require('./routes/documents'));
app.register(require('./routes/webhooks'));
app.listen({ port: 3000, host: '0.0.0.0' });
Tenant Resolution: The Plugin That Runs First
Every request hits a tenant context plugin before it touches a route handler. The plugin extracts a tenant identifier, loads the tenant record, attaches it to the request, and refuses the request if the tenant can't be resolved. Nothing else runs until this step succeeds.
We accept the tenant ID from the JWT claim for authenticated routes, and from a subdomain (acme.example.com) for public-facing flows like signup and password reset. The fallback for unauthenticated endpoints is the X-Tenant-ID header, but only on routes explicitly marked as public.
// packages/api/src/plugins/tenant-context.ts
import { FastifyPluginAsync } from 'fastify';
import fp from 'fastify-plugin';
import { loadTenant } from '../services/tenant-service';
export const tenantContextPlugin: FastifyPluginAsync = fp(async (app) => {
app.decorateRequest('tenant', null);
app.addHook('preHandler', async (req, reply) => {
const tenantId =
req.user?.tenantId ??
(req.hostname.split('.')[0] !== 'www' && req.hostname.split('.')[0]) ||
req.headers['x-tenant-id'];
if (!tenantId) {
return reply.code(400).send({ error: 'tenant_required' });
}
const tenant = await loadTenant(tenantId as string);
if (!tenant || tenant.status !== 'active') {
return reply.code(404).send({ error: 'tenant_not_found' });
}
req.tenant = tenant;
});
});
The plugin runs as a single Fastify preHandler hook. It is the only place in the codebase allowed to call loadTenant outside of admin endpoints. Centralizing resolution here means every route handler can trust req.tenant without re-checking.
Data Isolation: Shared Schema With a Tenant Column
We chose the shared-schema, shared-database model with a tenant_id column on every tenant-scoped table. This is the cheapest option operationally, and it is the most dangerous one if you forget a WHERE clause. To compensate, we built two layers of defense.
Layer 1: A typed wrapper around the database client. Every query goes through a function that automatically appends the tenant filter based on the request context. Routes never call the raw Prisma client.
// packages/db/src/scoped-client.ts
export function scopedClient(tenantId: string) {
return {
projects: {
findMany: (args: Prisma.ProjectFindManyArgs) =>
prisma.project.findMany({
...args,
where: { ...args.where, tenantId },
}),
findUnique: (args: Prisma.ProjectFindUniqueArgs) => {
if (!args.where.id) throw new Error('id required');
return prisma.project.findFirst({
where: { id: args.where.id, tenantId },
});
},
create: (args: Prisma.ProjectCreateArgs) =>
prisma.project.create({
...args,
data: { ...args.data, tenantId },
}),
// ...update, delete, count
},
// documents, users, billing, etc.
};
}
Layer 2: A Postgres row-level security (RLS) policy. Even if a developer forgets to use the scoped client, the database itself refuses cross-tenant reads. Every tenant table has an RLS policy keyed off a session variable set at the start of every transaction.
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant')::uuid);
// every transaction
await prisma.$transaction(async (tx) => {
await tx.$executeRawUnsafe(
`SET LOCAL app.current_tenant = '${tenantId}'`,
);
return tx.project.findMany();
});
This belt-and-braces approach has caught real bugs. The application layer stops 99% of mistakes at code review time, and RLS stops the 1% that slip through before they reach production data.
Auth, Tenants, and Membership
A user can belong to multiple tenants. We model this with a Membership join table that records (user_id, tenant_id, role). The JWT carries user_id plus a tenant_id representing the currently active tenant, and the client can switch tenants via a POST /auth/switch-tenant endpoint that issues a new JWT.
Roles are simple: owner, admin, member, viewer. We check them in route guards and in the scoped client itself. The scoped client refuses to mutate resources when the caller's role is below the required threshold.
const guard: FastifyPluginAsync = fp(async (app) => {
app.addHook('preHandler', async (req, reply) => {
const required = req.routeOptions.config.minRole;
const actual = roleRank(req.membership.role);
if (actual < roleRank(required)) {
return reply.code(403).send({ error: 'forbidden' });
}
});
});
Billing Without a Spreadsheet
Stripe handles subscriptions. Each tenant maps to one Stripe customer. Webhooks from Stripe (invoice.paid, customer.subscription.updated, customer.subscription.deleted) update a subscriptions table that the tenant context plugin checks during load. If a subscription becomes past_due for more than 7 days, the plugin returns tenant_suspended and all routes return 402.
// packages/api/src/routes/webhooks/stripe.ts
app.post('/webhooks/stripe', {
config: { public: true },
}, async (req, reply) => {
const event = stripe.webhooks.constructEvent(
req.rawBody,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET!,
);
switch (event.type) {
case 'customer.subscription.updated':
await syncSubscription(event.data.object);
break;
case 'customer.subscription.deleted':
await cancelSubscription(event.data.object);
break;
// ...
}
return reply.send({ received: true });
});
Background Jobs and Tenant Context
BullMQ jobs also need a tenant context. We store the tenantId on every job's data payload and re-run the set_config step inside the worker before processing. Workers never trust the in-memory request context.
// packages/workers/src/processors/document-processor.ts
worker.process(async (job) => {
const { tenantId, documentId } = job.data;
await prisma.$executeRawUnsafe(
`SET LOCAL app.current_tenant = '${tenantId}'`,
);
return processDocument(documentId);
});
Observability for Multi-Tenant Systems
Logs and metrics must be filterable by tenant. We tag every log line and every Prometheus counter with tenant_id. In development this looks noisy. In production, when a customer reports a problem, it is the only reason we can answer support tickets in minutes instead of hours.
We also added a per-tenant rate limiter in Redis. The limit scales with the customer's plan, and the limits themselves live in the database so they can be adjusted without a deploy.
What We'd Do Differently
Three things.
First, we would set up RLS on day one rather than month three. Retrofitting policies onto existing tables required a careful backfill and a migration window.
Second, we would invest in tenant-scoped integration tests earlier. The tests we have now spin up an isolated Postgres schema per test, load a fixture tenant, and run every endpoint as a specific membership. Catching cross-tenant leaks in CI has saved us more than once.
Third, we would write a tenant linter rule that flags any Prisma call that doesn't go through the scoped client. Static analysis at the AST level catches the same bugs RLS catches at runtime, but earlier and cheaper.
The Payoff
Multi-tenancy is not a feature you add at the end. It is a constraint you have to design for from the first migration, the first endpoint, the first test. TypeScript helped because the type system lets us encode tenant scoping into the call signatures rather than relying on runtime discipline. Postgres RLS helped because the database is the last line of defense and it should be a real one.
The result is a platform that serves hundreds of tenants from a single deployment, with two engineers maintaining it, and with the kind of iteration speed we could not have gotten from a more isolated architecture.
If you're building something similar and you want to skip the part where you spend a week wiring up webhook-driven docs sync, DocPilot does it for you. DocPilot connects to a GitHub repo, watches your commits via webhooks, and keeps your README, API reference, and architecture docs in sync with the code. We use it ourselves for the project described above, and it's the closest thing to documentation that writes itself that we've found. Start with the free tier and you'll have working docs by the end of your coffee.
Read next
saas
How Modern SaaS Companies Use AI to Scale Documentation
Documentation is one of the first things that breaks when SaaS products scale. Here's how leading teams are using AI to keep it in lockstep with the codebase.
Read
microservices
How to Document Microservices Architecture at Scale
When your architecture splits into dozens of services, documentation becomes the difference between velocity and chaos. Here's how to keep it accurate, useful, and alive.
Read
architecture
The Case for AI-Generated Architecture Diagrams
Hand-drawn architecture diagrams rot the moment they're saved. AI-generated ones pull from code, so they tell the truth — even after the third refactor.
Read