如何用Next.js、Supabase与next-intl构建子域名多租户系统?
类似Sentry的多租户平台架构实现咨询
需求概述
我要搭建一个类似Sentry的多租户平台:
- 根域名(如
https://sentry.io/en)承载营销站点 - 每个租户拥有独立的本地化子域名(如
https://acme.sentry.io/en)
技术栈
- Next.js(App Router)
- Supabase(数据库+身份认证)
- next-intl(国际化处理)
当前状态与疑问
我已经在本地完成子域名解析测试,但不确定当前方案是否安全、具备生产就绪能力。想请教:
- 是否有人成功实现过该架构?
- 在Next.js应用中结合自定义子域名、Supabase Auth/DB与next-intl的推荐模式、示例或最佳实践有哪些?
当前实现细节
文件夹结构
-app --[locale] ---[subdomain] ----(authenticated) -----layout.tsx -----page.tsx ----(authentication) -----login ------page.tsx ---layout.tsx ---page.tsx
Middleware代码
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)$).*)" ] };
updateSession代码
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; }
内容的提问来源于stack exchange,提问作者Bert Van Hecke
相关产品推荐
相关产品推荐

