Subdomain multi-tenancy
Implement organization subdomains like org.turbostarter.dev with Next.js Proxy and TurboStarter organization slugs.
For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
This recipe shows how to add subdomain-based multi-tenancy to a TurboStarter app, so each organization can be reached through a hostname like acme.turbostarter.dev.
The implementation has two main pieces:
- TurboStarter's organization model, where each organization already has a stable slug
- Next.js Proxy that extracts the subdomain and rewrites the request to an organization-aware route
The key idea is simple: use the subdomain as another way to select the active organization, then keep the rest of your application working with the same organization-aware APIs and session patterns you already use elsewhere.
Capabilities
This approach is a good fit when:
- each organization should feel like it has its own workspace or app
- you want URLs like
org.turbostarter.devinstead of/dashboard/org - you already model organizations with a unique slug
- you want one codebase and one deployment serving many tenants
If your app only needs organization scoping inside an authenticated dashboard, keeping /dashboard/[organization] routes may stay simpler.
Architecture
Organization-aware pages already use a slug-based route:
const organizationSlug = (await params).organization;
const activeOrganization = await getOrganization({ slug: organizationSlug });That is the important foundation. A subdomain setup does not replace this idea, it just changes where the slug comes from.
With subdomain multi-tenancy, the request flow becomes:
- User opens
https://acme.turbostarter.dev - Proxy extracts
acmefrom the hostname - Proxy rewrites the request to an internal route such as
/app/acme - Your server component resolves the organization by slug
- The rest of the app continues using the resolved organization and membership data
Keep one source of truth
Use the organization slug as the canonical tenant identifier everywhere: URLs, lookups, cache keys, and permission checks.
Make sure organizations have stable slugs
TurboStarter already follows the right pattern: organizations are addressed by slug, and slug generation is checked for uniqueness.
export const generateSlug = async (name: string) => {
const base = slugify(name, {
lower: true,
remove: /[.,'+:()]/g,
});
let slug = base;
// retry with a suffix when the slug is taken
// ...
return { slug };
};For subdomain routing, your slug rules matter more than in path-based routing because the slug becomes part of the hostname.
Recommended constraints:
- lowercase only
- no spaces
- no underscores
- avoid very long labels
- reserve special hostnames such as
www,app,admin,docs,api
It is worth validating this at organization creation time so you never issue an invalid hostname.
Add a root domain env variable
Define one environment variable that represents the base domain your tenants live under.
NEXT_PUBLIC_ROOT_DOMAIN="turbostarter.dev"For local development, you can still point this to localhost-style development:
NEXT_PUBLIC_ROOT_DOMAIN="localhost:3000"Then expose a tiny helper:
export const protocol =
process.env.NODE_ENV === "production" ? "https" : "http";
export const rootDomain =
process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? "localhost:3000";This gives you a single place to reason about hostnames in your Proxy logic and redirects.
Add a Proxy that extracts the organization from the hostname
This is the core of the pattern. Use Next.js Proxy to:
- detect subdomains in local development
- detect subdomains in production
- rewrite matching requests to an internal route
In TurboStarter, rewrite a tenant hostname to a route that includes the organization slug:
import { type NextRequest, NextResponse } from "next/server";
const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? "localhost:3000";
const RESERVED_SUBDOMAINS = new Set(["www", "app", "admin", "docs", "api"]);
const getOrganizationSlugFromHost = (request: NextRequest) => {
const host = request.headers.get("host") ?? "";
const hostname = host.split(":")[0];
const root = rootDomain.split(":")[0];
if (hostname === root || hostname === `www.${root}`) {
return null;
}
if (
hostname.endsWith(".localhost") ||
request.url.includes("localhost") ||
request.url.includes("127.0.0.1")
) {
const match = request.url.match(/https?:\/\/([^.]+)\.localhost(?::\d+)?/);
return match?.[1] ?? null;
}
if (!hostname.endsWith(`.${root}`)) {
return null;
}
const slug = hostname.replace(`.${root}`, "");
return RESERVED_SUBDOMAINS.has(slug) ? null : slug;
};
export function proxy(request: NextRequest) {
const slug = getOrganizationSlugFromHost(request);
const { pathname } = request.nextUrl;
if (!slug) {
return NextResponse.next();
}
if (pathname.startsWith("/api") || pathname.startsWith("/_next")) {
return NextResponse.next();
}
return NextResponse.rewrite(new URL(`/app/${slug}${pathname}`, request.url));
}
export const config = {
matcher: ["/((?!api|_next|[\\w-]+\\.\\w+).*)"],
};This keeps hostname parsing at the edge while letting the rest of the app stay focused on loading the organization by slug.
Create an internal tenant route
Now create a route that receives the rewritten slug. A simple structure is:
src/app/app/[organization]/page.tsx
src/app/app/[organization]/layout.tsxThen resolve the organization exactly the same way TurboStarter resolves /dashboard/[organization] routes:
import { notFound, redirect } from "next/navigation";
import { getOrganization, getSession } from "~/lib/auth/server";
export default async function TenantLayout({
children,
params,
}: {
readonly children: React.ReactNode;
readonly params: Promise<{ organization: string }>;
}) {
const { user } = await getSession();
if (!user) {
redirect("/login");
}
const slug = (await params).organization;
const organization = await getOrganization({ slug });
if (!organization) {
notFound();
}
return <>{children}</>;
}Once you have the slug, the rest of the organization loading story stays familiar.
Reuse the existing active-organization flow
TurboStarter already treats the organization slug in the URL as the primary signal for organization context, and then syncs that with the authenticated session.
For example, the client hook reads the current slug from route params:
const params = useParams();
const slug = params.organization?.toString();
const activeOrganization = useQuery({
...organization.queries.get({ slug: slug ?? "" }),
enabled: !!slug,
});That means you do not need a totally separate tenant model for subdomains. You can keep:
- organization lookup by slug
- membership lookup by organization ID
- permission checks against the resolved organization
- session synchronization through
activeOrganizationId
In practice, subdomains become a new entrypoint into the same organization state model you already use for slug-based routes.
Decide whether to keep path-based dashboard URLs
You have two reasonable options:
Option A: Subdomain only
Use the rewrite target as the real application surface, for example:
acme.turbostarter.dev/acme.turbostarter.dev/settingsacme.turbostarter.dev/members
This feels the most native, but it means more of your app depends on hostname-based behavior.
Option B: Hybrid
Keep the existing /dashboard/[organization] routes for the main app, and use the subdomain only as a convenient entrypoint:
acme.turbostarter.devrewrites or redirects to/dashboard/acme- all deeper navigation stays path-based
This is usually the easiest migration path because you preserve almost all existing routing and layouts.
If you want the smallest possible change set, start with the hybrid approach.
Protect tenant boundaries in data access
The hostname should help select the tenant, but it should never be the only security boundary.
Keep all existing checks that verify:
- the organization exists
- the current user belongs to that organization
- the user has permission for the requested action
This is already how TurboStarter is structured: the slug gets you to the organization, and membership or role checks decide what the user may do inside it.
Important
Do not trust the subdomain by itself for authorization. Always resolve the organization, then verify membership and permissions server-side before returning tenant data.
Support local development
Use local subdomains such as:
http://tenant.localhost:3000
That is the easiest development setup for this pattern as well.
Examples:
http://acme.localhost:3000http://globex.localhost:3000
Make sure your Proxy handles:
localhost127.0.0.1- the port being present in the
hostheader
You can keep your root app on:
http://localhost:3000
and tenant apps on:
http://org.localhost:3000
Configure production deployment
For production, you need wildcard DNS for your application domain.
Typical setup:
- Add
turbostarter.devto your hosting provider - Add a wildcard record for
*.turbostarter.dev - Set
NEXT_PUBLIC_ROOT_DOMAIN=turbostarter.dev - Deploy the app with
proxy.tsenabled
Preview deployments need one extra consideration: the hostname may look different from production. If your hosting provider uses alternate preview hostnames, normalize those inside Proxy before extracting the organization slug.
Suggested file layout
One clean structure is:
If you prefer the hybrid approach, your Proxy can instead redirect or rewrite subdomains into the existing dashboard structure.
Common edge cases
- Reserved subdomains: Prevent users from creating organizations named
www,docs,api,admin, and similar internal hostnames. - Slug changes: If you allow organizations to rename their slug, old subdomains will break unless you store redirects or a hostname history table.
- Cross-tenant caching: Cache keys must include the organization slug or ID to avoid leaking data across tenants.
- Asset URLs and cookies: Cookies should usually be scoped to the parent domain when you want session sharing across subdomains.
- SEO and indexing: Private tenant workspaces should usually be
noindex. - Login flows: After sign-in, redirect the user back to the tenant hostname they started on instead of always sending them to the root domain.
Recommended rollout
The safest rollout is:
- keep the current organization slug routes
- add hostname parsing in
proxy.ts - rewrite
org.domain.cominto the existing organization-aware route tree - verify auth, membership, and caching behavior
- only then decide whether you want fully subdomain-native routes
That way you reuse the existing organization model from TurboStarter and only add the hostname routing layer.
How is this guide?
Last updated on