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.

The rule: tenant isolation lives in one place — the data layer. If a developer can write a query that escapes the tenant scope, the architecture is wrong, not the developer.

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.