You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何用Next.js、Supabase与next-intl构建子域名多租户系统?

类似Sentry的多租户平台架构实现咨询

需求概述

我要搭建一个类似Sentry的多租户平台:

  • 根域名(如https://sentry.io/en)承载营销站点
  • 每个租户拥有独立的本地化子域名(如https://acme.sentry.io/en)

技术栈

  • Next.js(App Router)
  • Supabase(数据库+身份认证)
  • next-intl(国际化处理)

当前状态与疑问

我已经在本地完成子域名解析测试,但不确定当前方案是否安全、具备生产就绪能力。想请教:

  1. 是否有人成功实现过该架构?
  2. 在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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.12 06:45:55