0

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:

  1. Next.js (App Router)
  2. Supabase (database + authentication)
  3. 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!

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.