BlogHub
2 min read
By Samir Gurung

Building subdomain multi-tenancy from scratch in NestJS

How I isolated tenant environments using subdomain routing, middleware guards, Prisma scoping — every decision behind Banau's architecture.

coding
Building subdomain multi-tenancy from scratch in NestJS

Why subdomains over paths?

Path-based routing (/store/merchant) means all tenants share one cookie domain. Tenant A's auth cookie is technically in the same scope as Tenant B's. That's a security boundary I didn't want. Subdomain routing (merchant.banau.app) gives each tenant a genuinely isolated cookie scope, its own CORS origin, and the ability to map a custom domain.

Resolving the tenant from the request

Every request to the API carries a Host header. A NestJS middleware reads the subdomain from that header and attaches the tenant to the request object before any controller sees it:

// tenant-resolver.middleware.ts
@Injectable()
export class TenantResolverMiddleware implements NestMiddleware {
  constructor(private prisma: PrismaService) {}

  async use(req: Request, res: Response, next: NextFunction) {
    const host = req.headers.host ?? '';
    const subdomain = host.split('.')[0];
    if (subdomain && subdomain !== 'www' && subdomain !== 'api') {
      const tenant = await this.prisma.tenant.findUnique({ where: { subdomain } });
      if (tenant) (req as any).tenant = tenant;
    }
    next();
  }
}

Scoping every query to a tenant

I extracted a private helper that throws if no tenant is found — it's impossible to accidentally return cross-tenant data:

private async getTenantByOwner(req: any) {
  const tenant = await this.prisma.tenant.findUnique({
    where: { ownerId: String(req.user.id) },
  });
  if (!tenant) throw new ConflictException('No tenant found');
  return tenant;
}

async getAllOrder(paginationDto: PaginationDto, req: any) {
  const tenant = await this.getTenantByOwner(req);
  return this.prisma.order.findMany({
    where: { tenantId: tenant.id }, // always tenant-scoped
    include: ORDER_FULL_INCLUDE,
  });
}

The tradeoff I accepted

Subdomains make local dev slightly annoyinglocalhost doesn't support subdomains natively. I solve it with /etc/hosts entries during development. In production a DNS wildcard record (*.banau.app) handles everything automatically.

Published on March 18, 2026