Next.js SaaS
How to Design a Multi-Tenant SaaS in Next.js: Isolation, Routing, and Data Ownership
Design tenant resolution, database ownership, authorization, cache keys, jobs, custom domains, and deployment for a multi-tenant Next.js SaaS.
Multi-tenancy is an invariant: every read, write, cache entry, job, log, and hostname must resolve to the intended tenant before privileged work begins.
Choose what a tenant means
A tenant may be an organization, workspace, store, or customer account. Define who creates it, who belongs to it, whether users can join several, and which records it owns. Use a stable tenant ID internally even when the public identifier is a slug or custom hostname.
Write the isolation promise. Shared infrastructure with row-level ownership is different from a database per tenant. The stronger model costs more to provision and operate but may be justified by regulation, scale, or customer requirements. Choose from the product's actual boundary rather than from an abstract purity test.
Resolve tenant context from trusted input
Path-based applications can resolve /acme/projects from the route slug. Subdomain and custom-domain applications resolve the request host to a tenant record. Normalize the host, reject unknown values, and never trust a tenant ID supplied by a form when the authenticated route already establishes the context.
Proxy can perform early hostname rewrites or optimistic routing, but secure resolution belongs in server code too. Pass a verified tenant context into the data layer; do not let every component independently parse headers and guess ownership.
export async function resolveTenant(host: string) {
const normalized = host.toLowerCase().split(":")[0];
const tenant = await db.tenant.findUnique({
where: { hostname: normalized },
});
if (!tenant) notFound();
return tenant;
}Enforce ownership in every query
Put tenantId on shared-table records and include it in every lookup, update, delete, and uniqueness constraint. A globally unique record ID is not an authorization check. Query by both the requested ID and verified tenant ID so a leaked identifier cannot cross the boundary.
Database row-level security can provide defense in depth where the stack supports it, but application authorization remains necessary. Test the negative path deliberately by creating two tenants and attempting every protected operation with the other tenant's identifiers.
const project = await db.project.findFirst({
where: {
id: projectId,
tenantId: context.tenantId,
},
});Make roles tenant-specific
A user can be an owner in one tenant and a viewer in another. Store roles on membership records, not as one global field on the user. Resolve the membership after the tenant, then check the exact permission required by the operation.
Avoid scattering role strings across components. Centralize permission rules in functions that return domain decisions, and repeat the check inside Server Actions and Route Handlers. The interface may hide unavailable controls for clarity, but server authorization is what protects the operation.
Partition caches, jobs, and storage
Every shared cache key needs tenant identity. A key named projects can leak one tenant's result to another; projects:tenant-id establishes the boundary. Apply the same rule to cache tags, rate limits, object-storage paths, search indexes, job payloads, and idempotency keys.
Background workers must re-resolve authorization or operate from a trusted immutable tenant context recorded when the job was created. Include tenant-safe identifiers in logs, but do not log private customer content merely to make debugging convenient.
"use cache";
cacheTag("projects:" + tenantId);
return db.project.findMany({ where: { tenantId } });Treat custom domains as verified configuration
A tenant should not claim a hostname by typing it into a form. Require a verification step before routing traffic, prevent a hostname from belonging to two tenants, and define what happens when ownership changes. Keep the platform's own domains separate from customer-managed domains.
Generate canonical URLs from the verified tenant hostname when public tenant pages are indexable. Authenticated dashboards normally should not be indexed. Make redirects and cookies compatible with the hostname model, especially when users move between a central login domain and a tenant domain.
Test isolation as a system property
Create automated fixtures for at least two tenants and two roles. Exercise direct pages, Server Actions, Route Handlers, exported files, caches, jobs, search, and host resolution. A test that only hides another tenant's navigation item does not test isolation.
Review database queries and logs for missing tenant predicates. Add uniqueness constraints that include tenantId where names need only be unique inside a tenant. Test tenant deletion and export so background data does not survive without an owner accidentally.
Deploy tenant routing with observable releases
Adios keeps Next.js routing, the persistent runtime, database dependency, secret references, logs, and custom-domain configuration connected to the application release. Test a platform hostname, a verified tenant hostname, an unknown host, two tenant accounts, and the health route on the preview before promotion.
Build and runtime logs help distinguish a failed release from a tenant-specific data error. Managed TLS and promotion keep verified public routes on the healthy version while source and manifest changes remain reviewable. Tenant isolation still belongs to the application and data design; the hosting layer makes that design deployable and inspectable.
name: multi-tenant-app
build_cmd: npm ci && npm run build
start_cmd: npm start
runtime:
name: node@24
port: 3000
health_path: /api/health
secrets:
DATABASE_URL: secret://DATABASE_URL