next-subrouterをリリースしました
単一の Next.js プロジェクトで、複数のサブドメインに対応できる npm パッケージをリリースしました。
README は英語なので、ざっくりと仕様を書いていこうと思います。
1 つのリポジトリで複数のサービスを展開するケースって結構多いと思うんですが。
同一のサービスでペルソナだけ異なるみたいなケースですら、モノレポで対応するのってめんどくさいなーというのがずっとありました。
よくあるケースとしては、以下のような感じですかね?
- エンドユーザー向け(hogefuga.com)
- 管理向け(studio.hogefuga.com)
- 社内向け(admin.hogefuga.com)
で、これらをそれぞれに Next.js でプロジェクトを作成して、共通コンポーネントを作って、Linter や Formatter を設定して、みたいなことをやるのがもう嫌で嫌で。
細かい懸念は山程あるけれども、そういうのは全部一旦置いておいて、せめてプライベートの開発くらい 1 つのプロジェクトで管理させてくれ!!!!と常々思っていました。
ということで ChatGPT に相談したところ、middleware で解決できるよ?とアドバイスを頂きまして。
それならばと、Claude Code にお任せでプロトタイプを出力してもらったところ、思いのほか良い感じに動いてくれまして。
じゃあ横展開したいよね、ということで npm パッケージにまで落とし込みました。
まだ α 版に近い状態なのであまり期待してほしくはないのですが…。
通常の単言語版と、next-intl に対応したマルチ原語版の 2 種類を内包しています。
加えて後者については、next-subrouter 自体を next-intl に依存させないように実装してあるので、カスタマイズもしやすくなっているはずです。
実装感については README に書いてあるのでここではあまり深く触れませんが、呼び出し自体はめっちゃくちゃシンプルです。
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 = {
// Match all pathnames except for
// - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel`
// - … the ones containing a dot (e.g. `favicon.ico`)
matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
};
もちろんローカルでの開発にも対応しており、単純に hosts を書き換えて localhost の向き先にサブドメインを追加してあげればあっさり動くと思います。
このパッケージを使用すると、Vercel のプロジェクトが 1 つで完結しちゃうのが個人的に 2 番目にデカいところかなと思っていまして。
多分おそらく開発コスト自体はガツンと下がると思います。
ただその分デメリットもありまして、例えば next-sitemap とかは多分単一のドメインしか対応していない?はず?かわし方はあるとは思いますが。
あと 1 つのプロジェクトで複数のペルソナに対応することになるので、root 直下のパスで別れてはいるものの、RootLayout は共通なので、そこらへんは繊細に扱う必要があります。
が、とはいえ、個人的にはそれ以上にメリットがデカいと思っているので、ぜひ興味がありましたら使っていただけますと。
また README には書いていないですが、Clerk のような認証系にも対応しています。
ちょっと動作確認が甘めですが、参考までに。
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)(.*)",
],
};
今のところパッケージ内でサポートする予定はないので、よしなに実装していただけますと。
そんな感じです、久しぶりにかなり大型の npm パッケージをリリースしました。
が、実装は 10 割 Claude Code まかせなので、めっちゃくちゃ楽ではありました。
とはいえ、プロンプトについてはやはり気を使うのと、仕様自体は把握していないと、トークンがすぐに尽きてしまいますし、毎回正しい実装を出力してくれるわけでもないので、そこらへんは気をつけないとですね。