Building Multitenant Experience for my Project Management App
2025-06-13 . 3 min read
Introduction
When I set out to add multitenancy support to my Project Management app, I knew I wanted a clean UX: every tenant gets their own subdomain meta.tdata.app
, alphabet.tdata.app
, etc. I was already using Next.js and Supabase Auth deployed to Vercel, and figured this would be straightforward.
It wasn’t. But it was worth it.
This blog includes how I went from tdata.app to full-fledged subdomain-based multitenancy, the roadblocks I hit, and the solutions that made it work.
🧠 The Goal
Allow each organization to:
- Have a clean, isolated subdomain (tenant.tdata.app)
- Authenticate using Supabase
- Use Next.js Middleware to detect the current tenant
- Seamlessly protect routes and redirect users accordingly
Step 1: 🌐 Setting Up Your Domain for Subdomains
To enable tenant-specific subdomains like tenant1.tdata.app
, you need to configure your domain correctly with Vercel.
Here’s a quick summary:
- Point your domain’s nameservers to Vercel’s nameservers.
- Add your apex domain (e.g.,
tdata.app
) in your Vercel project settings. - Add a wildcard domain (
*.tdata.app
) to support all tenant subdomains.
With this setup, Vercel automatically handles routing and issues SSL certificates for each tenant subdomain.
📚 The full domain management process is well documented here:
Multi-Tenant Domain Management – Vercel Docs
🔍 Step 2: Extracting the Subdomain
Next challenge: reliably extracting the subdomain across dev and prod.
I wrote a simple utility adapted from here: Vercel Pltform Starter Kit
function extractSubdomain(request: NextRequest): string | null {
const host = request.headers.get("host") || ""; // tenant.localtest.com:3000
const hostname = host.split(":")[0]; // tenant.localtest.com
const isSubdomain = hostname !== RootDomainFormatted && !hostname.startsWith("www.");
return isSubdomain ? hostname.split(".")[0] : null;
}
🔐 Step 3: Auth with Supabase + Subdomains
Next.js Middleware lets you intercept requests; perfect for auth logic. But here's the kicker: Supabase Auth relies on cookies, and subdomains complicate this.
⚠️ Problem: Supabase couldn't read the auth cookies on subdomains.
By default, cookies are scoped to the subdomain they're set from. This means if the app is running on tdata.app, the cookie won't be available on tenant.tdata.app or other subdomains.
🔧 Fix: Add custom cookie options
When initializing Supabase, I had to set custom cookie options to ensure the cookies are scoped to the correct domain. Setting domain .tdata.app makes the cookie accessible across all subdomains.
const supabase = createServerClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
cookies: {...},
cookieOptions: {
domain: ApexDomain,
path: '/',
maxAge: MAX_AGE_IN_SECONDS,
sameSite: 'lax',
},
});
This is critical for consistent auth state across subdomains in a multitenant setup. With current design, a user authenticated once are authorized to all domains they are a member of.
⚠️ But wait, this won't work locally
By default, browsers don’t allow setting cookies on .localhost or its subdomains. This breaks Supabase Auth, which depends on cookie-based sessions. To simulate production-like behavior in development, we need to use a custom domain that:
- Resolves to 127.0.0.1 (loopback)
- Supports subdomains
- Allows setting scoped cookies across subdomains (e.g. .localtest.com)
A common trick is to update your local hosts file to point a fake domain to your local server:
127.0.0.1 localtest.com
127.0.0.1 www.localtest.com
✅ now localtest.com resolves to 127.0.0.1, and browsers allow cookie scoping on it, unlike .localhost.
📍 Windows Users: The hosts file is located at:
C:\Windows\System32\drivers\etc\hosts
# You must run your editor as Administrator to modify it.
🧭 Step 4: Organization Scoping & Route Redirection
Before going multi-tenant via subdomains, tenant scoping in the app was handled using URL paths i.e. each tenant lived under /[orgKey]/..., e.g. /tenant/dashboard. To prepare for subdomain-based scoping, I refactored all tenant-specific routes under a clear namespace: /org/[orgKey]/.... This made tenant routing more consistent and easier to reason about, a clean separation between global pages (/signin, `/about, etc.) and tenant pages which also makes it clear while rewriting the request in middleware.
Once subdomain-based tenant resolution was in place, I used middleware to rewrite requests based on the subdomain. The subdomain itself represents a unique tenant. So for example a request to tenant.tdata.app/my-tasks gets rewritten to tdata.app/org/tenant/my-tasks.
Here’s the core logic from middleware.ts that handles this rewrite:
if (subDomain && user) {
const path = request.nextUrl.pathname;
const response = NextResponse.rewrite(
new URL(`${PathPrefix.org}/${subDomain}${path}`, request.url)
);
const cookiesToSet = supabaseResponse.cookies.getAll();
cookiesToSet.forEach(({ name, value }) => {
response.cookies.set(name, value);
});
return response;
}
This way, tenant-specific routing remains consolidated under a single directory. And since middleware handles the rewrite, there's no runtime difference between path-based and subdomain-based access.
🔥 Step 5: API Routes & CORS
Initially, all API routes were placed at the root level /api/route. Tenant context was passed via URL segments /api/[orgId]/route.
However, once subdomains were introduced, things broke. API requests made from a tenant subdomain tenant.tdata.app to the root tdata.app/api/route resulted in CORS errors. This is expected behavior.
At this point, I had two options:
-
Bypass CORS by enabling it properly and only relying on Authorization for security.
-
Move API routes under the tenant subdomain, just like pages, using the same rewrite logic.
I chose Option 2 for the following reasons:
-
Keeps tenant isolation tight; Every part of the app (pages + API) is scoped under the subdomain.
-
Middleware rewrite logic is already in place; Leveraging it keeps the implementation DRY.
To implement this, I simply moved all tenant API routes under the /org/[orgKey]/api/route path. With the middleware rewrite active, a request to tenant.tdata.app/api/route gets rewritten to tdata.app/org/tenant/api/route.
No more CORS issues. Tenant context is always clear. And the codebase has a much cleaner separation between public/global APIs and tenant-specific functionality.
References
-
Supabase Auth + Subdomain discussion: https://github.com/orgs/supabase/discussions/3198#discussioncomment-5550787
-
Vercel Multitenancy Guide: https://vercel.com/docs/multi-tenant/domain-management
-
YouTube Tutorial on Multi-Tenant SaaS with Subdomains: https://www.youtube.com/watch?v=vVYlCnNjEWA&t=72s
Hey I assume you finished reading, I would love to know your feedback or if found any error or mistake in this blog post, please do not hesitate to reach out to me.