SaaS Architecture

Architecting a Single-Backend SaaS: Gating features without infrastructure sprawl

Building a multi-tenant Link-in-Bio product can quickly lead to complex hosting setups. Here is how we managed multiple tiers on one shared backend.

By Rehan Idrisi · · Updated · 5 min read

When launching a SaaS product, it is tempting to separate user tiers by hosting separate code deployments or spawning dedicated databases. That does provide isolation. It also multiplies maintenance overhead by ten, because every migration, every security patch and every feature now has to land N times instead of once.

While building a link-in-bio platform for local vendors, we opted for a single shared backend codebase. Access to premium tools such as customized designs or native payment collections is feature-gated at the logical level, not the infrastructure level. This is the breakdown of how to make multi-tenancy boring and clean.

Keep the data schema unified

Instead of isolating databases, we used a shared database model where every table includes a tenant ID or user owner ID. A global middleware layer automatically scopes SQL queries to the current authenticated tenant's ID.

The leak gets prevented at the application entrypoint. One vendor cannot query or modify another vendor's layout or payment credentials, because a query never reaches the database unscoped.

Isolate tenants logically via database scoping middleware, not physically via server deployments.

Gating features at the code level

The permission-check system is lightweight. A user profile payload returned from the database carries a 'tier' field: 'free', 'pro', 'enterprise'.

Every API route request runs a middleware check against that tier. A free user who attempts to enable custom CSS styling or configure a payment gateway is rejected immediately with an HTTP 403 Forbidden, before any handler runs.

function checkFeatureAccess(requiredTier) {
  return (req, res, next) => {
    const userTier = req.user.tier; // e.g. 'free'
    const tiers = { free: 0, pro: 1, enterprise: 2 };
    
    if (tiers[userTier] < tiers[requiredTier]) {
      return res.status(403).json({
        error: 'Feature locked. Upgrade required.'
      });
    }
    next();
  };
}

Enforcing limits and usage quotas

Gating covers more than an on/off switch, because quotas matter just as much. A free user might be capped at 5 links while a pro user gets unlimited links. We implement that by running aggregation counts before inserting records.

The count query runs inside the active write transaction. That is what guarantees a user cannot slip past their tier limit by firing concurrent API requests.

Handling upgrades and subscription lifecycles

A subscription event fires, say a user paying through the payment gateway, and we receive a secure webhook payload on the server. The webhook updates the tenant's tier column inside the users table.

Because the feature gates read current database state on every request, the tenant gains access the moment that column changes. No redeployment. No container restarts.

The one place the frontend needs to know about tiers too

Server-side gating is the security boundary. A backend-only check still makes for a confusing product when the UI doesn't reflect it, and a free-tier user should not be able to fill in a custom-domain field only to discover after submitting that it was locked the whole time. The frontend reads the same tier field and shows locked features as visibly disabled with an upgrade prompt. That is a UX affordance and nothing more. Every one of those routes is re-checked server-side regardless of what the client displayed, since a client-side-only lock is trivial to bypass with a direct API call.

What breaks in a shared-database model, and how we caught it

The failure mode worth designing against is not a clever attack. It is an ordinary bug where a new query forgets the tenant-scoping filter and returns rows across tenants. We caught this class of bug in two layers. Code review carries a lint-style check looking specifically for raw queries against tenant-scoped tables that bypass the scoping middleware. An integration test suite then creates two tenants with overlapping data, on purpose, and asserts that every read endpoint returns only the requesting tenant's rows. That second layer has caught a scoping regression before it reached production more than once.

Noisy-neighbour risk in a shared backend

One shared backend means one tenant's unusual usage pattern can slow things down for everyone else. A sudden spike in API calls, a very large export request: with fully isolated infrastructure that risk simply doesn't exist, so it is a genuine trade-off to account for. We mitigate it with per-tenant rate limits on expensive operations such as exports and bulk updates, and by moving heavy jobs onto a background queue instead of the request path, so no single tenant's burst of activity blocks the API workers serving everyone else.

The benefits of single deployments

  • Zero-downtime updates: one code push updates the application for all users instantly.
  • You pay for one web service and one central database cluster, so the hosting bill stays flat.
  • No cross-environment syncing or multi-deployment migrations to test.
  • A security issue gets patched in one place, instead of N deployments each needing the same fix applied and verified separately.

A SaaS with 10 infrastructure instances is 10 times harder to keep secure. Keep it singular and gate access at the logical layer.

How do you gate premium features without running separate infrastructure per tier?

Keep one shared database and codebase, scope every query by tenant ID through a global middleware, and check the user's subscription tier at the API-route level instead of deploying separate environments per plan. Webhooks update the tier field on payment events, so upgrades take effect immediately with zero redeploys.

Share this guideLinkedInXWhatsAppFacebook
All guides