Full Source Code View the complete repo on GitHub
Multi-Tenancy in TanStack Start: Subdomain & Hostname Routing
Building a SaaS usually requires identifying a tenant by their subdomain or hostname. Because TanStack Start is built on top of Nitro and Vinxi, we have powerful server-side utilities to handle this during the SSR (Server-Side Rendering) phase.
Here is the goal: Two subdomains, one codebase, completely different branding.
Tenant 1 with custom branding and logo.
Tenant 2 with custom branding and logo.

1. Normalize the Hostname
In production, you'll have tenant.com or user.saas.com. In development, you likely have localhost:3000. This utility ensures your logic stays consistent across environments.
// lib/normalizeHostname.ts
export const normalizeHostname = (hostname: string): string => {
// Handle local development subdomains like tenant.localhost:3000
if (hostname.includes("localhost")) {
return hostname.replace(".localhost", "").split(":")[0];
}
return hostname;
};
2. Identify the Tenant (Server Function)
We use createServerFn to ensure our tenant lookup—which might involve a database call or a secret API key—never leaks to the client. We use getRequestUrl() from the Start server utilities to grab the incoming URL.
// serverFn/tenant.serverFn.ts
import { getTenantConfigByHostname } from "#/lib/api";
import { normalizeHostname } from "#/lib/normalizeHostname";
import { createServerFn } from "@tanstack/react-start";
import { getRequestUrl } from "@tanstack/react-start/server";
export const getTenantConfig = createServerFn().handler(async () => {
const url = getRequestUrl();
const hostname = normalizeHostname(url.hostname);
const tenantConfig = await getTenantConfigByHostname({ hostname });
if (!tenantConfig) {
// You can throw a 404 here, or return null to handle it in the UI
throw new Response("Tenant Not Found", { status: 404 });
}
return tenantConfig;
});
3. Register in the Root Loader
The best place to fetch tenant data is the __root__ route. This ensures the data is resolved once at the top level and is available to every child route and the HTML <head>.
// routes/__root.tsx
import { getTenantConfig } from "#/serverFn/tenant.serverFn";
export const Route = createRootRoute({
staleTime: Infinity ,
loader: async () => {
try {
const tenantConfig = await getTenantConfig();
return { tenantConfig };
} catch (error) {
if (error instanceof Response && error.status === 404) {
return { tenantConfig: null };
}
throw error;
}
},
// ...
});
-
add staleTime: Infinity, //to stop fetching the getTenantConfig again on client side navigation
4. Dynamic Metadata & UI
One of the biggest benefits of this approach is SEO. You can dynamically update the page title, favicon, and Open Graph tags based on the tenant.
Updating the <head>
// routes/__root.tsx
export const Route = createRootRoute({
head: (ctx) => {
const tenant = ctx.loaderData?.tenantConfig;
return {
meta: [
{ title: "tenant?.meta.name ?? \"Default App\" },"
{ name: "description", content: tenant?.meta.description },
{ property: "og:image", content: tenant?.meta.logo },
],
links: [{ rel: "icon", href: tenant?.meta.favicon ?? "/favicon.ico" }],
};
},
});
Using Tenant Data in Components
Access the data anywhere using useLoaderData from the root.
// routes/index.tsx
import { createFileRoute, useLoaderData } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: HomePage,
});
function HomePage() {
const { tenantConfig } = useLoaderData({ from: "__root__" });
if (!tenantConfig) return <h1>404: Tenant Not Found</h1>;
return (
<main className="p-6">
<img src={tenantConfig.meta.logo} alt="Logo" width={100} />
<h1>Welcome to {tenantConfig.meta.name}</h1>
</main>
);
}
Pro-Tips for Multi-Tenancy
-
Caching: Wrap your
getTenantConfigByHostnamein a cache (likeReact.cacheor a Redis layer) to avoid hitting your database on every single page load. - Security: Always validate that the identified tenant is active and not suspended before returning the config.
- Assets: If you use a CDN, ensure your image paths are absolute or prefixed correctly to avoid cross-domain loading issues.

Top comments (0)