The stack
Next.js (App Router) on the front and the edge, a typed API layer, PostgreSQL as the source of truth, and a small set of well-chosen services for auth, billing and jobs. TypeScript end to end. Boring, predictable infrastructure so the interesting work goes into the product.
app/ → routes, layouts, server components
(marketing)/ → public, SSR/SSG, SEO-indexed
(app)/ → authenticated product, per-tenant
api/ → route handlers (thin)
lib/ → domain logic, validation, db access
db/ → schema, migrations, seed
Multi-tenancy
We default to a shared database with a tenant ID on every row, enforced at the data-access layer — not sprinkled through route handlers. Every query goes through a scoped client that injects the current organisation's ID, so it is structurally impossible to read another tenant's data by forgetting a WHERE clause.
Auth & role-based access
Authentication is a bought commodity — we use a managed provider and never roll our own password storage. Authorization is ours. Roles and permissions live in our database, checked in the data layer and surfaced through a single can(user, action, resource) helper. UI hides what you can't do; the server enforces it.
The API layer
Route handlers stay thin — parse, authorize, delegate, respond. All the real logic lives in lib/ as plain functions that are trivial to test without HTTP. Every input is validated with a schema at the boundary.
export async function POST(req) {
const session = await requireSession(req);
const input = CreateProjectSchema.parse(await req.json());
await authorize(session, "project:create");
const project = await createProject(session.orgId, input);
return Response.json(project, { status: 201 });
}
Billing & Stripe webhooks
Stripe is the source of truth for subscription state; our database mirrors it — kept in sync only through webhooks, never by trusting the client redirect after checkout. Webhook handlers are idempotent and signature-verified. Entitlements are derived from the mirrored subscription, checked in the same authorization layer as everything else.
Background jobs
Anything slow, retryable or scheduled goes to a queue, not the request path — emails, exports, AI calls, third-party syncs. A request enqueues; a worker processes with retries and backoff.
Observability
From day one: structured logs with request and tenant IDs, error tracking with source maps, and basic product analytics. When something breaks you want "which tenant, which request, what input" in seconds.
A SaaS isn't hard because any one piece is hard. It's hard because the pieces have to compose cleanly under real users. Get the seams right and the features get easy.
If you're starting a SaaS and want a foundation that won't need rebuilding at Series A, let's talk.