DEV Community

Cover image for Multi-Tenancy in TanStack Start: A Simple Guide
harshG775
harshG775

Posted on Edited on

Multi-Tenancy in TanStack Start: A Simple Guide

Multi-Tenancy in TanStack Start: Subdomain & Hostname Routing

Full Source Code: View the complete repo on GitHub

If you're building a SaaS, chances are you need to figure out which tenant is making the request — usually based on subdomain or hostname. TanStack Start makes this pretty clean with two pieces that work together: request middleware, which runs before routing even kicks in, and server functions, which act as the RPC layer for pulling that resolved data into your components.

The end goal for this post: one codebase, two subdomains, two completely different brands.

Tenant 1, with its own logo and branding:
Tenant 1

Tenant 2, same app, totally different look:
Tenant 2


1. Figuring out who the tenant is

You could resolve the tenant inside a server function that your loader calls. But it's cleaner to do it in global request middleware instead — that way it runs on literally every server request (page loads, server routes, server functions, all of it) before anything else touches the request.

// src/lib/server/tenant.middleware.ts
import { createMiddleware } from "@tanstack/react-start"
import { getTenantByHostname } from "../db"

const normalizeHostname = (hostname: string | null): string => {
    if (!hostname) {
        return ""
    }

    let finalHostname = hostname

    // Development handling (e.g., tenant-1.com.localhost:3000)
    if (hostname.includes("localhost")) {
        const cleaned = hostname.replace(".localhost", "").replace(/:\d+$/, "")
        finalHostname = cleaned
    }

    return finalHostname
}

export const tenantMiddleware = createMiddleware({ type: "request" }).server(async ({ request, next }) => {
    const hostname = normalizeHostname(request.headers.get("host"))

    const tenant = await getTenantByHostname(hostname)
    return next({
        context: { tenant },
    })
})
Enter fullscreen mode Exit fullscreen mode

Then register it globally in src/start.ts, right alongside CSRF protection:

// src/start.ts
import { createStart, createCsrfMiddleware } from "@tanstack/react-start"
import { tenantMiddleware } from "./lib/server/tenant.middleware"

const csrfMiddleware = createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" })

export const startInstance = createStart(() => {
    return { requestMiddleware: [csrfMiddleware, tenantMiddleware] }
})
Enter fullscreen mode Exit fullscreen mode

Because it's global instead of per-route, context.tenant just shows up in every server function automatically. You won't think much of this until you add your first mutation that needs to scope a write to the current tenant — then you'll be glad you don't have to wire it up again.

2. A lookup that doesn't hit the DB every time

Hitting the database on every single request just to check the hostname is wasteful, so we wrap it in a small TTL cache. Nothing tenant-specific baked in — it's generic on purpose:

// src/lib/cache.ts
export type Cache<T> = {
    has: (key: string) => boolean
    get: (key: string) => T | undefined
    set: (key: string, value: T) => void
}

export function createCache<T>(ttlMs: number): Cache<T> {
    type Entry = { value: T; expiresAt: number }
    const store = new Map<string, Entry>()
    const isExpired = (entry: Entry) => entry.expiresAt <= Date.now()

    return {
        has: (key) => {
            const entry = store.get(key)
            return entry !== undefined && !isExpired(entry)
        },
        get: (key) => {
            const entry = store.get(key)
            if (!entry || isExpired(entry)) return undefined
            return entry.value
        },
        set: (key, value) => {
            store.set(key, { value, expiresAt: Date.now() + ttlMs })
        },
    }
}
Enter fullscreen mode Exit fullscreen mode

And the "database" itself — shaped like Drizzle's db.query.<table>.findFirst/findMany on purpose, so dropping in real Drizzle later doesn't mean rewriting anything:

// src/lib/db/index.ts
import { createCache } from "../cache"

export type TenantType = {
    id: string
    hostname: string
    meta: { name: string; description: "string; logo: string; favicon: string }"
}

export const tenants: TenantType[] = [
    /* ...seed data... */
]

function createTable<T>(rows: T[]) {
    return {
        findFirst: async (options?: { where?: (row: T) => boolean }) =>
            rows.find(options?.where ?? (() => true)),
        findMany: async (options?: { where?: (row: T) => boolean; limit?: number }) => {
            const matches = rows.filter(options?.where ?? (() => true))
            return options?.limit === undefined ? matches : matches.slice(0, options.limit)
        },
    }
}

export const db = { query: { tenants: createTable(tenants) } }

const TENANT_CACHE_TTL_MS = 60_000 // 1 minute
const tenantCache = createCache<TenantType | undefined>(TENANT_CACHE_TTL_MS)

export const getTenantByHostname = async (hostname: string) => {
    if (tenantCache.has(hostname)) {
        return tenantCache.get(hostname)
    }

    const tenant = await db.query.tenants.findFirst({ where: (t) => t.hostname === hostname })
    tenantCache.set(hostname, tenant)
    return tenant
}
Enter fullscreen mode Exit fullscreen mode

One thing worth calling out: we cache misses too (undefined gets cached, not skipped). Otherwise an unknown hostname would keep hammering the "database" on every request. And since it's TTL-based, when a tenant's data actually changes, it just shows up naturally within a minute — no cache invalidation to remember. When you're ready for production, swap the Map for Redis behind the same has/get/set interface and nothing else changes.

3. Reading the tenant back out

Middleware resolved it — now a server function reads it and turns "no tenant found" into an actual 404 instead of leaking undefined downstream:

// src/lib/server/tenant.function.ts
import { notFound } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"

export const getTenantFn = createServerFn({ method: "GET" }).handler(async ({ context }) => {
    if (!context.tenant) {
        throw notFound()
    }

    return context.tenant
})
Enter fullscreen mode Exit fullscreen mode

Notice context.tenant is already typed here — no need to re-attach tenantMiddleware with .middleware([...]), since it's registered globally.

4. Resolving it once, at the root

Call getTenantFn() in the root route's beforeLoad. It runs once per navigation and lands straight in router context:

// src/routes/__root.tsx
import { getTenantFn } from "#/lib/server/tenant.function"

export const Route = createRootRoute({
    beforeLoad: async () => {
        const tenant = await getTenantFn()
        return { tenant }
    },
    // ...head() below
})
Enter fullscreen mode Exit fullscreen mode

Since getTenantFn already throws before ever returning undefined, everything downstream can just assume tenant exists. No try/catch, no tenant: null branch to handle later.

5. SEO that actually reflects the tenant

Now that tenant is guaranteed to be there, head() can generate real SEO metadata per tenant — title, description, Open Graph, Twitter cards, all of it:

export const Route = createRootRoute({
    beforeLoad: async () => {
        const tenant = await getTenantFn()
        return { tenant }
    },
    head: ({ match }) => {
        const tenant = match.context.tenant

        const title = tenant.meta.name || "TanStack Start Starter"
        const description = tenant.meta.description || "A TanStack Start application"
        const favicon = tenant.meta.favicon || "/favicon.ico"
        const logo = tenant.meta.logo || "/logo.png"
        const url = `https://${tenant.hostname}${match.pathname}`

        return {
            meta: [
                { charSet: "utf-8" },
                { name: "viewport", content: "width=device-width, initial-scale=1" },
                { title },
                { name: "description", content: description },
                { property: "og:title", content: title },
                { property: "og:description", content: description },
                { property: "og:image", content: logo },
                { property: "og:url", content: url },
                { name: "twitter:card", content: "summary_large_image" },
                { name: "twitter:title", content: title },
                { name: "twitter:description", content: description },
                { name: "twitter:image", content: logo },
                { name: "twitter:url", content: url },
            ],
            links: [
                { rel: "stylesheet", href: appCss },
                { rel: "icon", href: favicon },
            ],
        }
    },
    shellComponent: RootDocument,
})
Enter fullscreen mode Exit fullscreen mode

One small detail: og:url and twitter:url are built from tenant.hostname, not the raw request URL. Link-preview crawlers need the real production domain — not a local .localhost dev address.

6. Using it inside your components

Any route under root can just pull tenant from context with Route.useRouteContext(). No loader gymnastics, no null checks:

// src/routes/index.tsx
import { createFileRoute } from "@tanstack/react-router"

export const Route = createFileRoute("/")({ component: App })

function App() {
    const { tenant } = Route.useRouteContext()

    return (
        <main className="page-wrap px-4 pb-8 pt-14">
            <div className="flex items-center gap-4">
                <img src={tenant.meta.logo} alt={tenant.meta.name} width={64} height={64} className="rounded-full" />
                <div>
                    <h1 className="text-2xl font-bold">{tenant.meta.name}</h1>
                    <p>{tenant.meta.description}</p>
                    <small>Hostname: {tenant.hostname}</small>
                </div>
            </div>
        </main>
    )
}
Enter fullscreen mode Exit fullscreen mode

7. A 404 that doesn't feel out of place

Since the 404 gets thrown from the root route's beforeLoad, you can't just drop a notFoundComponent on a single route — set it at the router level instead:

// src/router.tsx
export function getRouter() {
    return createTanStackRouter({
        routeTree,
        scrollRestoration: true,
        defaultPreload: "intent",
        defaultPreloadStaleTime: 0,
        defaultNotFoundComponent: () => (
            <div className="flex min-h-screen items-center justify-center p-6 text-3xl font-semibold">
                Page not found.
            </div>
        ),
    })
}
Enter fullscreen mode Exit fullscreen mode

A few things worth keeping in mind

The cache here is a proof-of-concept — good enough for a demo, but you'll want Redis behind the same interface once this hits production; its built-in EX/PX expiry maps cleanly onto the TTL logic already there.

Resolving the tenant globally rather than per-route feels like overkill at first, but it pays off the moment you add a mutation that needs tenant scoping — you already have it, for free.

Don't forget to check that a tenant is actually active before returning its config — a suspended tenant shouldn't render like a normal one just because the hostname matched.

And decide early how you want to handle an unmatched hostname: hard 404, or redirect to an onboarding/marketing page. Either is fine, just be consistent about where that decision lives (tenant.function.ts is a natural spot).

Last thing — watch your asset URLs. Logos and favicons especially need to be absolute or correctly prefixed for a CDN, since they're being loaded across different domains now.

Top comments (0)