Skip to content
Kamran Code

How to Design a Scalable SaaS Architecture

Tenancy, billing, background work, environments and the boundaries that let a SaaS product grow without a rewrite. A practical architecture for a first version that is built to last, with Next.js, Laravel and PostgreSQL as the reference stack.

Author
Kamran Khan
Published
Updated
Reading time
7 min read
On this page
  1. The decisions that are expensive to change later
  2. Tenancy
  3. Identity and organisations
  4. The domain and its boundaries
  5. Plans, entitlements and billing
  6. Background work from the start
  7. The API is a product surface
  8. Reference stack
  9. Environments and deployment
  10. Observability
  11. Growing without a rewrite

"Scalable" is usually said about traffic. For a SaaS product the harder kind of scale is organisational: more customers with different needs, more plans, more integrations, more people working on the codebase. The architecture decisions that matter most are the ones that stop all that from forcing a rewrite in year two.

This is the shape I use for a first SaaS version. It's small enough to ship in months and structured enough to grow.

The decisions that are expensive to change later

Most things can be refactored gradually. Five can't, so decide them first:

  1. The tenancy model: how customer data is isolated.
  2. Identity: how users, organisations and roles relate.
  3. The domain boundary: what the product owns, and what it integrates with.
  4. Background work. The queue is part of the system from the start, not an add-on.
  5. The shape of your environments and deployment.

The rest (UI framework details, which email provider, caching strategy) can evolve.

Tenancy

Almost every B2B SaaS is multi-tenant: many customer organisations share one deployment. The question is how their data is kept apart.

The first option is a shared database with a tenant column. Every row carries a tenant_id and every query filters on it. It's the simplest to operate and the most efficient, and it's what I recommend for nearly every first version. The risk is a missed filter leaking data between tenants. Guard against it in three places: a global query scope at the ORM level (Laravel's global scopes, or Prisma/Drizzle middleware), row-level security in PostgreSQL as a second net, and tests that assert isolation.

The alternatives are a schema or a database per tenant. They isolate more strongly but cost much more to operate, because migrations, connections and backups multiply across hundreds of schemas. They're justified when customers demand isolation in the contract or a single tenant's data is very large. That's rare in a first version.

Whichever you choose, hide it behind a Tenant context that each request resolves once (from the subdomain, the session or an API key) and that all data access reads from. If tenancy is resolved in one place, you can change the model later. If it's scattered through controllers, you can't.

Identity and organisations

Model three things separately from day one. A user is a person with credentials. An organisation is the tenant: the customer account, with its plan and settings. A membership is a user's role within an organisation.

Let users belong to many organisations; consultants, agencies and people who change jobs will thank you. Put roles on the membership, not the user. With this shape, invitations, SSO and "switch organisation" are easy to add. Without it, each one is painful.

The domain and its boundaries

Write down what the product owns. A scheduling product owns appointments, availability and reminders. It doesn't own invoicing, email delivery or identity. Everything it doesn't own is an integration behind an interface:

Product core  ──►  Billing provider (Stripe or similar)
              ──►  Email/SMS provider
              ──►  Identity providers (Google, Microsoft)
              ──►  Customer's other tools (via API + webhooks)

Put an adapter between the core and each provider. The adapter is thin, but it stops the provider's data model leaking into yours, so when you change email providers the core doesn't notice.

Plans, entitlements and billing

Billing is where first versions get tangled. Keep three concepts apart. A plan is a named commercial package: Starter, Team, Enterprise. Entitlements are the concrete capabilities and limits a tenant has right now, such as seats, projects, API access and feature flags. A subscription is the billing state from the provider: active, past due, cancelled or on trial.

Code checks entitlements, never plan names: if (tenant.can('api_access')), not if (tenant.plan === 'team'). Entitlements come from the plan plus any overrides (a custom deal, a trial extension), and they update when the billing provider sends a webhook. Sales can then sell exceptions without engineering changes, and you can repackage plans without touching feature code.

Billing webhooks must be verified and idempotent, and every event should be stored before it's processed. A missed or double-processed payment event is the kind of bug that costs you customers.

Background work from the start

A SaaS product does more work outside requests than inside them: emails, exports, imports, webhooks to customers, scheduled reports, billing sync, search indexing. Design the queue in from the beginning.

You need a job system with retries, backoff and a dead-letter queue, such as Laravel Horizon on Redis or a Node worker on a managed queue. Jobs must be idempotent, because a retried job has to be safe, and they should carry the tenant context explicitly. Periodic work needs a scheduler with overlap protection. Put queue depth, failure rate and job duration on a dashboard in the first week.

Long-running or heavy work, like large imports or AI processing, runs as jobs that report progress. It never runs inside a request that can time out.

The API is a product surface

Even if the first version has no public API, build the internal one as though it will be public. Version the routes (/api/v1) and keep resource shapes and the error format consistent. Authenticate with per-tenant API keys that carry scoped permissions, and rate-limit per tenant. Outbound webhooks should be signed and retried, with a delivery log customers can see.

Customers who wire your product into their own tools are much harder to lose, and supporting that later is far harder if the internal API grew ad hoc.

Reference stack

The architecture above doesn't depend on a framework. For a concrete first version, I usually reach for one of two approaches. The first is Next.js (App Router, server components) for the product interface and the marketing site. The second is Laravel as the API and job system, with a React or Next.js frontend when the interface is interactive enough to justify the split; for simpler products, Laravel with server-rendered views and targeted interactivity is faster to build and easier to run.

Underneath either one: PostgreSQL, with row-level security as the second isolation net, and Redis for queues, cache and rate limiting. Billing goes through a Stripe-style provider behind an adapter, with entitlements stored locally. Everything ships as Docker images to a container platform or managed service, with staging and production kept separate and secrets injected at runtime.

Every one of those choices is mature and well understood, and in a first version that matters more than anything new.

Environments and deployment

Run local development in the same containers as production. Where you can, give every pull request its own preview environment, which makes reviews much faster. Keep a staging environment that mirrors production, including the billing provider in test mode. In production, run migrations as a deploy step, roll out with zero downtime, and keep a rollback path.

Test backups by restoring them, on a schedule, somewhere other than production. Until you've done a restore, you don't know you have backups.

Observability

From launch, you want structured logs carrying tenant and user IDs, error tracking, request and job metrics, and a few product numbers: sign-ups, activations, active tenants and failed payments. Alert on error rate, queue backlog and billing webhook failures. This is a day-one job because the first month after launch is when you learn the most and have the least data.

Growing without a rewrite

You can tell the first-version architecture is holding when:

  • new features fit inside the existing boundaries instead of working around them;
  • a tenant's data can be exported or deleted with one command, because tenancy is explicit;
  • a plan can be repackaged in configuration;
  • the queue absorbs spikes without customers seeing failures;
  • a provider can be swapped by changing one adapter.

When one of those stops being true, that's the module to invest in next.

  • #saas
  • #architecture
  • #multi-tenancy
  • #nextjs
  • #laravel
  • #postgresql
  • #devops

Kamran Khan

I build AI-powered applications, full-stack platforms and business software — across full-stack development, business systems, automation and production AI.

About Kamran →

Keep reading

  • LLM Applications7 min read

    How to Integrate AI Into an Existing Web Application

    You do not need a rewrite to add AI to a Laravel or Next.js application. A step-by-step approach: find the right feature, isolate the model behind a service, validate outputs, handle cost and failure, and ship behind a flag.

    • #ai-integration
    • #laravel
    • #nextjs
    • #llm
  • Software Architecture7 min read

    What Happens When You Press Upload on YouTube

    A viral post sketched YouTube's backend with no sources. I checked each part against primary documents: what's documented and what's only a guess.

    • #system-design
    • #youtube
    • #video-processing
    • #vitess

Have a project in mind?

Tell me what you're trying to build. I'll come back with an honest view on scope, architecture and the right approach.