I’m trying to build a multi-tenant platform similar to how Sentry works, where the root domain (e.g. https://sentry.io/en) serves the marketing site, and each tenant has its own localised subdomain (e.g. https://acme.sentry.io/en).
My stack is:
- Next.js (App Router)
- Supabase (database + authentication)
- next-intl (for internationalization)
I’ve experimented locally and can get subdomains to resolve, but I’m not confident that the approach I took is secure or production-ready.
Has anyone successfully implemented this setup? Are there recommended patterns, examples, or best practices for combining custom subdomains, Supabase Auth/DB, and next-intl in a Next.js app?
My current folder structure looks like this:
-app
--[locale]
---[subdomain]
----(authenticated)
-----layout.tsx
-----page.tsx
----(authentication)
-----login
------page.tsx
---layout.tsx
---page.tsx
For my middleware i have:
import createMiddleware from "next-intl/middleware";
import { type NextRequest } from "next/server";
import { routing } from "@/i18n/routing";
import { updateSession } from "@/lib/supabase/middleware";
const handleI18nRouting = createMiddleware(routing);
export const protocol =
process.env.NODE_ENV === "production" ? "https" : "http";
export const rootDomain =
process.env.NEXT_PUBLIC_ROOT_DOMAIN || "localhost:3000";
export function extractSubdomain(request: NextRequest): string | null {
const url = request.url;
const host = request.headers.get("host") || "";
const hostname = host.split(":")[0];
// Local development environment
if (url.includes("localhost") || url.includes("127.0.0.1")) {
// Try to extract subdomain from the full URL
const fullUrlMatch = url.match(/http:\/\/([^.]+)\.localhost/);
if (fullUrlMatch && fullUrlMatch[1]) {
return fullUrlMatch[1];
}
// Fallback to host header approach
if (hostname.includes(".localhost")) {
return hostname.split(".")[0];
}
return null;
}
// Production environment
const rootDomainFormatted = rootDomain.split(":")[0];
// Handle preview deployment URLs (tenant---branch-name.vercel.app)
if (hostname.includes("---") && hostname.endsWith(".vercel.app")) {
const parts = hostname.split("---");
return parts.length > 0 ? parts[0] : null;
}
// Regular subdomain detection
const isSubdomain =
hostname !== rootDomainFormatted &&
hostname !== `www.${rootDomainFormatted}` &&
hostname.endsWith(`.${rootDomainFormatted}`);
return isSubdomain ? hostname.replace(`.${rootDomainFormatted}`, "") : null;
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const subdomain = extractSubdomain(request);
if (subdomain) {
const response = handleI18nRouting(request);
const supabaseResponse = await updateSession(request, response, subdomain);
// Check if updateSession returned a 404 and return it immediately
if (supabaseResponse.status === 404) {
return supabaseResponse;
}
const normalizedPathname = pathname.replace(/^\/[a-zA-Z]{2}/, "") || "/";
const defaultLocale =
request.headers.get("x-default-locale") || routing.defaultLocale;
const url = new URL(
`/${defaultLocale}/${subdomain}${normalizedPathname}`,
request.url
);
supabaseResponse.headers.set("x-middleware-rewrite", url.toString());
return supabaseResponse;
}
const response = handleI18nRouting(request);
return response;
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
and
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
import { routing } from "../../i18n/routing";
import { Locale } from "../../i18n/type";
import { getPathname } from "../../i18n/navigation";
import { getEnvVar } from "../utils";
export async function updateSession(
request: NextRequest,
response: NextResponse,
subdomain: string
) {
const supabaseUrl = getEnvVar(
process.env.NEXT_PUBLIC_SUPABASE_URL,
"NEXT_PUBLIC_SUPABASE_URL"
);
const supabaseAnonKey = getEnvVar(
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY,
"NEXT_PUBLIC_SUPABASE_PUBLISHABLE_OR_ANON_KEY"
);
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
});
// Do not run code between createServerClient and
// supabase.auth.getClaims(). A simple mistake could make it very hard to debug
// issues with users being randomly logged out.
// IMPORTANT: DO NOT REMOVE auth.getClaims()
const { data } = await supabase.auth.getClaims();
const pathname = request.nextUrl.pathname;
const localeMatch = pathname.match(/^\/([a-zA-Z]{2})(\/|$)/);
const potentialLocale = localeMatch?.[1];
// Runtime check AND type guard
const isValidLocale = (val: unknown): val is Locale =>
typeof val === "string" && routing.locales.includes(val as Locale);
const locale: Locale = isValidLocale(potentialLocale)
? potentialLocale
: routing.defaultLocale;
// Localized routes
const paths = {
login: getPathname({ href: `/login`, locale }),
otpSuccess: getPathname({ href: `/login/otp-success`, locale }),
authCallback: getPathname({ href: `/auth/callback`, locale }),
authConfirm: getPathname({ href: `/auth/confirm`, locale }),
};
const { data: tenant, error } = await supabase
.from("tenants")
.select("*")
.eq("subdomain", subdomain)
.single();
if (!tenant || error) {
return new NextResponse(null, { status: 404 });
}
const loginUrl = new URL(paths.login, request.nextUrl.origin).toString();
// Redirect unauthenticated users trying to access protected pages
const publicPaths = [
paths.login,
paths.otpSuccess,
paths.authCallback,
paths.authConfirm,
];
if (!data?.claims && !publicPaths.includes(pathname)) {
return NextResponse.redirect(loginUrl);
}
return response;
}
Thanks guys!