Released: next-subrouter for Multi-Subdomain Routing in Next.js

2025-08-02

I've just released a new npm package called next-subrouter, which enables support for multiple subdomains within a single Next.js project.

Since the README is written in English, I wanted to share a high-level overview and thoughts behind the package in blog form.


In many projects, especially in private development or early-stage products, it's common to want to deploy multiple services from a single monorepo—or even better, a single project.

Take a typical use case like this:

  • Public site: hogefuga.com
  • Admin portal: studio.hogefuga.com
  • Internal tools: admin.hogefuga.com

Creating separate Next.js projects for each, maintaining shared components, managing linter/formatter configs, and deploying separately—it all becomes exhausting very quickly.

So I found myself thinking:

“Can I please just manage all this in a single project?”


I brought the problem to ChatGPT, and it suggested I could solve it via middleware.

Then I turned to Claude Code for prototyping—and the results were surprisingly promising.

So I decided to generalize it and publish it as an npm package.


The package is still in an alpha state, so please don’t expect too much just yet.

It includes two variants:

  • A basic version for single-language setups.
  • An advanced version with next-intl integration, for multilingual support.

Even the next-intl version does not depend on next-intl directly, which keeps the implementation modular and customizable.

Basic usage is extremely simple. Here's a minimal example:

import { createSubrouterMiddleware, type SubRoutes } from "next-subrouter";

const subRoutes = [
  { path: "/admin", subdomain: "admin" },
  { path: "/studio", subdomain: "studio" },
  { path: "/user" }, // default route (no subdomain)
];

export const middleware = createSubrouterMiddleware(subRoutes);

export const config = {
  matcher: "/((?!api|trpc|_next|_vercel|.*\..*).*)",
};

It also works fine in local development—just update your hosts file to point subdomains to localhost.


One major benefit is that you only need a single Vercel project. That’s a huge win in terms of development cost and mental overhead.

Of course, there are trade-offs.

For instance, tools like next-sitemap may not fully support multi-domain setups out of the box—you’ll likely need to work around those.

Also, while subdomain-based routing is handled cleanly, things like RootLayout remain shared, so you’ll need to design layouts carefully for each persona.

Still, I believe the benefits far outweigh the downsides.


By the way, although it’s not in the README yet, this setup also works with authentication libraries like Clerk.

Here’s an extended example:

import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import createIntlMiddleware from "next-intl/middleware";
import { createIntlSubrouterMiddleware } from "next-subrouter";
import { type NextRequest, NextResponse } from "next/server";
import { routing } from "./i18n/routing";

type Persona = "admin" | "studio" | "user";

const intlMiddleware = createIntlMiddleware(routing);
/**
 * Subdomain route configuration for next-subrouter
 */
const subRoutes = [
  { path: "/admin", subdomain: "admin" },
  { path: "/studio", subdomain: "studio" },
  { path: "/user" }, // default route (no subdomain)
];
/**
 * Create the subrouter middleware with internationalization
 */
const subrouterMiddleware = createIntlSubrouterMiddleware(
  subRoutes,
  intlMiddleware,
  {
    debug: process.env.NODE_ENV === "development",
    defaultLocale: routing.defaultLocale,
    locales: [...routing.locales],
  },
);
/**
 * Public paths configuration for each persona
 */
const PUBLIC_PATHS: Record<Persona, string[]> = {
  admin: [
    "/:locale/sign-in(.*)",
    "/:locale/sign-up(.*)",
    "/sign-in(.*)",
    "/sign-up(.*)",
    "/api/webhooks(.*)",
  ],
  author: [
    "/:locale/sign-in(.*)",
    "/:locale/sign-up(.*)",
    "/sign-in(.*)",
    "/sign-up(.*)",
    "/api/webhooks(.*)",
  ],
  user: [
    "/",
    "/:locale",
    "/:locale/sign-in(.*)",
    "/:locale/sign-up(.*)",
    "/sign-in(.*)",
    "/sign-up(.*)",
    "/api/webhooks(.*)",
  ],
};
/**
 * Cached route matchers for each persona to avoid recreation
 */
const routeMatchers = new Map<Persona, ReturnType<typeof createRouteMatcher>>();

/**
 * Creates or returns cached route matcher for the given persona
 */
function isPublicRouteFor(
  persona: Persona,
): ReturnType<typeof createRouteMatcher> {
  if (!routeMatchers.has(persona)) {
    // eslint-disable-next-line security/detect-object-injection
    const paths = PUBLIC_PATHS[persona];

    routeMatchers.set(persona, createRouteMatcher(paths));
  }

  return routeMatchers.get(persona)!;
}

/**
 * Cached persona detection to avoid repeated string operations
 */
const personaCache = new Map<string, Persona>();
/**
 * Create subdomain lookup map from subRoutes configuration
 */
const subdomainToPersonaMap = new Map<string, Persona>(
  subRoutes
    .filter((route) => Boolean(route.subdomain))
    .map((route) => [route.subdomain!, route.path.slice(1) as Persona]),
);

/**
 * Determines the persona (application section) based on subdomain with caching
 */
function getPersonaFromHostname(hostname: string): Persona {
  if (personaCache.has(hostname)) {
    return personaCache.get(hostname)!;
  }

  // Extract subdomain from hostname
  const subdomain = hostname.split(".")[0];
  const persona = subdomainToPersonaMap.get(subdomain) ?? "user";

  personaCache.set(hostname, persona);

  return persona;
}

/**
 * Main middleware function that handles multi-domain routing with internationalization and authentication
 */
export default clerkMiddleware(
  async (auth, request: NextRequest): Promise<NextResponse> => {
    // Skip processing for static files to improve performance
    const pathname = request.nextUrl.pathname;

    if (
      pathname.includes(".") &&
      !pathname.includes(".json") &&
      !pathname.includes(".html")
    ) {
      return NextResponse.next();
    }

    // First, apply subrouter middleware for subdomain routing and internationalization
    const subrouterResponse = subrouterMiddleware(request);
    // Handle authentication based on the original request's subdomain
    const hostname = request.headers.get("host");

    if (!hostname) {
      return subrouterResponse;
    }

    const cleanHostname = hostname.split(":")[0];
    const persona = getPersonaFromHostname(cleanHostname);
    // Check if route is public before doing expensive auth operations
    const isPublicRoute = isPublicRouteFor(persona);

    if (isPublicRoute(request)) {
      return subrouterResponse;
    }

    // Perform auth check for protected routes
    const { redirectToSignIn, userId } = await auth();

    if (!userId) {
      const signInResponse = redirectToSignIn();

      return NextResponse.redirect(signInResponse.url, signInResponse);
    }

    return subrouterResponse;
  },
  { debug: process.env.NODE_ENV === "development" },
);

export const config = {
  matcher: [
    // Skip Next.js internals and all static files, unless found in search params
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    // Always run for API routes
    "/(api|trpc)(.*)",
  ],
};

No plans to officially support Clerk in the package for now—but feel free to adapt as needed.


That’s all for now! It’s been a while since I’ve released something this large to npm.

That said, I delegated 100% of the code generation to Claude Code—which made development shockingly easy.

Still, prompts need to be well thought-out, and unless you already understand the spec, you’ll burn through tokens fast or get unexpected results.

So stay sharp—but have fun.

Hope it helps someone out there!