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.

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 annoying — localhost 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.