Saikat
← Back to blog

Multi-Tenant SaaS Across Multiple Domains: NestJS Backend + Next.js Frontend + One Admin Panel

July 15, 2026·8 min read
SaaSNestJSNext.jsMulti-Tenant
Share:
Multi-Tenant SaaS Across Multiple Domains: NestJS Backend + Next.js Frontend + One Admin Panel

Every SaaS eventually hits the same question: do we give every customer their own URL, or do we shove everyone onto one domain with paths? The answer is almost always "give them their own domain" — but doing it cleanly is the part most tutorials skip. Here's the architecture I ship when a client says "I want each tenant on their own domain, and I want to manage all of it from one admin panel."

The shape of the problem

You have:

  • One Next.js frontend codebase
  • One NestJS backend codebase
  • A list of tenants, each with its own domain (e.g. acme.acmeapp.com, client.io)
  • One admin user (you) who provisions tenants, maps domains, sets plans, and watches usage

The naive approach — fork the frontend per tenant — dies on day one. The correct approach is a single deployment that resolves the tenant from the incoming request, then behaves accordingly.

The architecture

acme.acmeapp.com  ─┐
beta.acmeapp.com  ─┼──▶  Next.js (single deploy)  ──▶  NestJS API
client.io         ─┘                ▲
                                    │
                          admin.acmeapp.com  (super-admin panel)

Three pieces:

  1. The API (NestJS) — one process, one database, every row scoped to a tenant_id.
  2. The customer frontend (Next.js) — one deploy, tenant resolved from the request hostname.
  3. The admin panel — same Next.js app, gated by role, served at a separate domain (or path).

DNS is the only per-tenant thing you actually need to set up. The application code stays single-tenant-shaped.

Resolving the tenant in NestJS

A TenantMiddleware reads the Host header and looks up the tenant. Subdomain matching is fine for predictable hostnames; for custom domains you'll need a domains table and a lookup.

// tenant.middleware.ts
@Injectable()
export class TenantMiddleware implements NestMiddleware {
  constructor(private readonly tenants: TenantsService) {}

  async use(req: Request, res: Response, next: NextFunction) {
    const host = req.headers.host?.split(':')[0]; // strip port
    const tenant = await this.tenants.findByHost(host);

    if (!tenant) {
      throw new NotFoundException(`No tenant for host: ${host}`);
    }

    req['tenant'] = tenant;
    next();
  }
}

Register it globally in AppModule:

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(TenantMiddleware).forRoutes('*');
  }
}

For the admin panel, you exclude the admin path or check for a special host:

const ADMIN_HOSTS = ['admin.acmeapp.com', 'localhost'];
const host = req.headers.host?.split(':')[0];
if (ADMIN_HOSTS.includes(host)) {
  return next(); // skip tenant resolution
}

The cleanest version is actually two NestJS apps behind one reverse proxy — one for the API, one for the admin API — sharing the same database but with different middleware. That way your customer API has zero admin surface area to leak.

Decorating requests with a tenant context

Once the middleware runs, every controller and service should know the current tenant. A custom decorator + a request-scoped service makes this explicit:

// tenant.decorator.ts
export const CurrentTenant = createParamDecorator(
  (data: unknown, ctx: ExecutionContext): Tenant => {
    const req = ctx.switchToHttp().getRequest();
    return req.tenant;
  },
);
@Controller('projects')
export class ProjectsController {
  constructor(private readonly projects: ProjectsService) {}

  @Get()
  list(@CurrentTenant() tenant: Tenant) {
    return this.projects.findAllForTenant(tenant.id);
  }
}

The service layer never takes a tenant ID as a parameter — it grabs it from the request context. That makes it impossible to forget the WHERE tenant_id = ? clause, because there isn't one to forget; the scope is implicit.

If you want to be extra safe, use a request-scoped provider that wraps your services' data access and refuses queries without a tenant context. This is paranoid but pays off the first time someone refactors a service and forgets the scope.

Resolving the tenant in Next.js

Same idea, different shape. In Next.js, getServerSideProps (or middleware, for App Router) sees the request hostname.

For the Pages Router:

// pages/_middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export async function middleware(req: NextRequest) {
  const host = req.headers.get('host')?.split(':')[0];

  if (host === 'admin.acmeapp.com') {
    return NextResponse.rewrite(new URL('/admin', req.url));
  }

  // pass tenant slug downstream
  const slug = host?.split('.')[0];
  const requestHeaders = new Headers(req.headers);
  requestHeaders.set('x-tenant-slug', slug ?? '');

  return NextResponse.next({ request: { headers: requestHeaders } });
}

For the App Router (cleaner):

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(req: NextRequest) {
  const host = req.headers.get('host') ?? '';
  if (host.startsWith('admin.')) {
    return NextResponse.rewrite(new URL('/admin', req.url));
  }
  // ... set tenant header, etc.
}

export const config = {
  matcher: ['/((?!_next|api|favicon.ico).*)'],
};

Then in any server component or route handler, read the tenant from the header and pass it to your data fetchers.

The admin panel

The admin panel is a Next.js app living at admin.acmeapp.com (or /admin on a separate host). It authenticates the operator (not the tenant user) and exposes:

  • Tenant CRUD — create, suspend, delete tenants.
  • Domain management — assign a subdomain or a custom domain, view DNS verification status.
  • Plan & billing — set plan tier, view invoices, override limits.
  • Impersonation — log in as a tenant to debug their issues. This is the single most useful admin feature. Guard it heavily (audit log + time-limited session).
  • Usage metrics — per-tenant request count, storage, MAU.

For authentication, keep the admin's user table separate from tenant users. Don't share a JWT secret, don't share a session cookie domain. An admin compromise should not be a tenant compromise.

Custom domains — the part nobody warns you about

Subdomains (*.acmeapp.com) are easy: a wildcard A record points to your load balancer, done. Custom domains (client.io) are where it gets interesting:

  1. Verification. When a tenant adds client.io, generate a unique TXT record (acme-verify=abc123) and ask them to add it. Poll DNS until it resolves.
  2. Provisioning. Once verified, you still need a TLS certificate. Two paths:
    • Use a service like Caddy + Cloudflare that issues certs automatically on first request.
    • Use a wildcard cert for your subdomains and an ACME client (Caddy, certbot, or acme.sh) to issue per-domain certs on the fly.
  3. Routing. Your reverse proxy (Caddy, Nginx, Traefik) needs to know which custom domain points to your app. A common pattern is to have tenants point a CNAME to a stable hostname like customers.acmeapp.com, then Caddy handles the rest.

If you can avoid custom domains, do. Subdomains get you 90% of the "I want my own URL" feeling with 5% of the operational pain. Only offer custom domains once you actually have customers asking for them and you're ready to support the TLS/DNS workflow.

Database schema sketch

The whole system runs on a small set of tables:

tenants (
  id, slug, name, plan, status, created_at
)

domains (
  id, tenant_id, hostname, type ('subdomain'|'custom'),
  verified, cert_status, created_at
)

users (
  id, tenant_id, email, password_hash, role
)

admin_users (  -- separate from tenant users
  id, email, password_hash, mfa_secret
)

Every business table has a tenant_id column with an index. Every query filters by it. Every backup includes it. You can run SELECT * FROM projects and instantly know whose projects they are.

What goes wrong if you skip this

A few failure modes I've seen in production:

  • Forgotten WHERE tenant_id. One missing clause, and tenant A sees tenant B's data. Add a query-level test that asserts every business query has a tenant filter — fail the build if it doesn't.
  • Shared upload buckets. If tenants upload to the same S3 prefix without namespacing, you get leakage and overwrite bugs. Always prefix with tenants/{id}/.
  • Cache collisions. Redis keys without a tenant prefix will happily serve tenant A's response to tenant B. Always namespace cache keys by tenant.
  • Cross-tenant API calls. A service that "needs" data from another tenant's data is a code smell. Either it doesn't, or you have a real cross-tenant integration problem (analytics, billing rollup) that should be done with an explicit, audited data flow.

When the system grows up

A few things that earn their keep once you have >50 tenants:

  • A tenant feature flag system — per-tenant toggles for beta features, with a kill switch in the admin panel.
  • Per-tenant rate limits — token buckets stored in Redis, keyed by tenant.
  • A scheduled job runner that scopes everything by tenant (nightly reports, billing, cleanup).
  • A migration tool that runs schema changes per tenant if you go schema-per-tenant (heavier, but the strictest isolation you can get).

None of this is needed on day one. The architecture above is enough to ship a working multi-tenant SaaS to your first 10 customers. Add the rest when usage tells you to.