Feb 12, 2025 ·
Multi-Tenancy Three Ways
Multi-tenancy sounds like one architectural decision until the first request enters the system. In practice, tenant context often gets resolved at the backend, the frontend, and the edge of routing, each with a different trust model.
Multi-tenancy sounds like one architectural decision until the first request enters the system. In practice, tenant context often gets resolved at the backend, the frontend, and the edge of routing, each with a different trust model.
This use case came from a product stack that had tenant-aware behavior in three places. A Django backend attached organization context to each request. A Next.js frontend derived a tenant hostname to fetch public theme data. A newer Next.js routing layer rewrote a small set of public pages based on a template cookie.
Those pieces can coexist, but only when each layer has a clear job. Backend tenant context can participate in authorization. Frontend hostname parsing can shape public presentation. Cookie-based template routing can choose a visual subtree. Cookie-based template routing is not authorization.
Resolve tenant context on the backend
The backend middleware had the strongest responsibility because it attached tenant context to the request object used by server-side application code. The resolver checked inputs in a defined order: a controlled organization-domain header, browser origin headers, request host, host without port, subdomain, then a development fallback.
When it found an active organization, it attached that organization to the request. When nothing matched, it attached an explicit empty value. That explicit fallback matters. Downstream code should not have to guess whether tenant resolution ran.
Django's middleware docs describe middleware as the hook layer around request and response processing. This is a natural place to put request-scoped organization lookup because every view receives the resolved context through the request. Django's request docs also matter here: HttpRequest.get_host() derives the host from trusted request metadata, and Django's settings docs note that ALLOWED_HOSTS validation is enforced through that path.
That host validation caveat is not academic. Multi-tenant routing based on hostnames must be strict about which headers are trusted. A controlled header can be useful for API clients and internal services, but only if the deployment path controls who may set it. Origin and referer headers can help browser-driven flows, but they are request headers, not identity. Host and subdomain routing work well for public tenant entry points, but only with correct host validation, proxy settings, DNS, and custom-domain ownership checks.
The middleware also reserved conventional service subdomains such as www, api, and admin. That prevents infrastructure names from becoming accidental tenant keys and gives maintainers one place to add more reserved names as the platform grows.
Parse hostnames for frontend experience
The frontend hostname pattern solved a different problem. It needed the current tenant domain so it could fetch public theme data and apply colors or branding client-side. The helper derived a domain from the browser hostname or request host, with an environment override for development.
This is tenancy as presentation context. It should not be confused with backend authorization. A theme lookup can make the same frontend shell look like the right tenant site, but it should not decide who can read private tenant data. The browser is not a secure authority for organization membership. It is a participant in routing and rendering.
This is still useful work. Tenant-specific branding, navigation, and public content often need to resolve before a user signs in. Hostname parsing lets the frontend shape the public experience for a custom domain or subdomain. The safe version treats that value as a lookup key for public presentation data, then lets authenticated backend APIs enforce access for private data.
The development override is practical because local hostnames often do not carry the same domain signal as production. The risk is configuration drift. A value that makes local development easy can hide a broken production hostname path if nobody tests the request-host branch directly.
Rewrite templates at the proxy layer
The template-routing layer moved a related but separate choice into Next.js Proxy. It read a template cookie, checked the value against a small allow-list, defaulted to a known template when the value was absent or invalid, and rewrote selected public routes into the chosen template subtree.
Next.js documents Proxy as code that runs before a request completes and can rewrite or redirect based on incoming data. It also documents NextResponse.rewrite() as preserving the browser URL while proxying to a different route, and cookies as a first-class request input in server code.
This is template routing, not authorization. The cookie selects which frontend subtree serves a route. It does not prove a user belongs to a tenant, and it should not unlock data. The allow-list is important because it prevents arbitrary cookie values from becoming arbitrary route targets, but an allow-list does not turn a presentation choice into an access-control boundary.
The pattern is good for demos, previews, white-label templates, and controlled visual variants. It is not enough for tenant isolation.
The matcher was intentionally narrow. It applied only to a small public route set, so the rewrite rule did not automatically capture every path in the app. That restraint is useful. A proxy that rewrites the whole site based on a cookie needs much stronger guarantees about route shape, data loading, and fallback behavior.
Use a trust order, not a guess
The backend middleware's strongest property was the explicit trust order. It did not ask every part of the application to parse hostnames on its own. It centralized the attempts and returned one request property.
That order deserves product and security review. A controlled header is convenient for integrations, but only if untrusted clients cannot spoof it. Browser headers can help infer tenant context for public flows, but they are not proof of identity. Subdomain routing can be clean, but only if reserved names and custom-domain ownership checks are handled. Local fallback helps development, but it should not mask missing configuration in production.
The trust order should also match support language. If support asks a customer for "the tenant domain," engineering should know whether that means the custom domain, the subdomain, the header value used by an integration, or the public hostname used by the theme query. Ambiguous language becomes production ambiguity.
For teams building a similar stack, write the trust order down. Put it in code, tests, and deployment notes. A tenant resolver that "usually figures it out" is hard to secure and harder to debug.
Implementation checklist
Start by naming the tenant identity source for each layer. Backend request handling may use a verified domain, subdomain, or controlled header. Frontend theming may use a public hostname. Template selection may use a cookie. Those are different contracts.
Next, decide which layer can authorize access. Usually that is the backend, because it can combine authenticated user state, organization membership, route context, and database policy. Do not let a cookie, browser hostname, or theme response become the final authority for private data.
Then make routing fallbacks visible. A missing backend tenant might mean public homepage, development fallback, or request rejection depending on route and product policy. An invalid template cookie might fall back to the default template. Those defaults should be intentional.
After that, test hostile inputs. Use an unknown host, a reserved subdomain such as admin, a forged organization-domain header, an invalid template cookie, and a valid template cookie for a route outside the matcher. The expected result should be boring: no private data, no arbitrary rewrite, no accidental tenant match.
Finally, keep tenant routing close to observability. Log which resolver path matched without logging sensitive headers wholesale. When a customer says their custom domain shows the wrong theme or their API request has no organization, the resolver path is the first fact you need.
When to use this pattern
Use this pattern when tenant identity genuinely appears at multiple layers: backend request handling, public frontend presentation, and route-level template selection. Do not rush to hide those differences behind one abstraction. First, write down what each layer is allowed to decide.
Avoid this pattern if a frontend cookie or browser-derived hostname would be used as the authority for private data. That is an authorization design problem, not a routing convenience.
If your application has tenant logic in several places, bring the request paths and trust assumptions. I can help separate backend authority, frontend presentation, and proxy routing before those concerns harden into one ambiguous tenant system.