Engineering

在單一 Next.js 部署上服務多個網域

ViralArc 小編 July 23, 2026 3 min read

在單一 Next.js 部署上服務多個網域:ViralArc 的 Specialty Server 實作

背景

ViralArc 的產品線比外界看到的多。除了主平台,我們還有功能精簡的輕量版、創作者接案市集、公開部落格、內部管理後台,以及嵌在各產品裡的 AI 助理——而且整個 app 支援六種語系。這些產品共用大量基礎設施:同一套登入、同一套資料模型、同一套 UI 元件庫、同一套 API 層。

一開始這些產品線都住在同一個 Next.js app 的不同路徑底下。問題出在對外發布:每條產品線都需要自己的網域和品牌,例如(以下皆為示意網域):

  • app.example.com → 主平台,完整功能
  • lite.example.com → 輕量版,只露出四、五個頁面
  • market.example.com → 接案市集,全站強制登入
  • blog.example.com → 部落格,完全公開、要被搜尋引擎收錄
  • admin.example.com → 內部後台,僅限員工

拆成多份部署不可行——共用邏輯每改一次要同步五份,對小團隊是災難。把 if (host === ...) 寫進各個頁面也不可行——判斷會散落到沒有人能回答「某個網域到底露出了哪些頁面」。

我們的做法是把「網域 → 產品」的映射收斂到兩個地方:一張宣告式註冊表描述每個網域的行為,一個 middleware 在請求進門時解析它。內部把註冊表裡的每一筆叫做一個 specialty server。以下按實作順序講。

第一步:用 Next.js 的路由結構把產品線隔開

所有產品線活在同一棵 app/ 路由樹上,用資料夾分支隔開。簡化後的結構長這樣:

app/
└── [locale]/                 # 六種語系,所有頁面都在底下
    ├── (public)/             # route group:行銷頁、定價頁
    │   ├── page.tsx
    │   └── pricing/page.tsx
    ├── (app)/                # route group:主平台
    │   ├── dashboard/page.tsx
    │   └── automations/page.tsx
    ├── (market)/
    │   └── market/           # 市集分支:實際路徑段
    │       ├── tasks/page.tsx
    │       └── payout/page.tsx
    ├── (blog)/
    │   └── blog/
    │       ├── page.tsx
    │       └── posts/[slug]/page.tsx
    └── (admin)/
        └── admin/
            └── ...

兩個 Next.js 的特性在這裡各司其職:

  • Route group(括號資料夾)不影響 URL,只用來隔開各產品的 layout——每個產品有自己的導覽列、主題、authentication provider,都掛在各自 group 的 layout.tsx 上。
  • 實際路徑段(如 market/)決定內部 URL。市集的任務頁在檔案系統上就是 /[locale]/market/tasks

所以每個頁面天然有一個「內部路徑」。接下來的所有工作,就是讓 market.example.com/tasks 這種乾淨的對外網址,對應到 /zh-TW/market/tasks 這個內部路徑——而且反向直接存取內部路徑時要擋下來。

第二步:宣告式註冊表

註冊表是一個 TypeScript 常數陣列,每筆描述一個網域的完整行為:

export type SpecialtyServer = {
  domains: string[];            // 綁定哪些網域(含 preview 網域)
  routesTo: string;             // 內部路由樹的分支,如 "/market"
  routeMode: "clean" | "prefixed";  // 對外網址是否隱藏分支前綴
  exposedRoutes: string[];      // 白名單:這個網域露出哪些頁
  exposedRoutePrefixes?: string[];  // 前綴白名單(動態路由用)
  passthroughRoutePrefixes?: string[];  // 不在分支下、但放行的頁
  publicRoutes?: string[];      // 免登入頁面
  requiresAuth?: boolean;       // 全站強制登入
  defaultVisiblePath: string;   // 走錯路時的落點
  chatVariant: ChatVariant;     // 這個網域上 AI 助理的模式
};

export const specialtyServerRegistry: SpecialtyServer[] = [
  {
    domains: ["market.example.com"],
    routesTo: "/market",
    routeMode: "clean",
    exposedRoutes: ["/", "/tasks", "/history", "/payout"],
    requiresAuth: true,
    defaultVisiblePath: "/tasks",
    chatVariant: "normal",
  },
  {
    domains: ["blog.example.com", "blog-preview.example.com"],
    routesTo: "/blog",
    routeMode: "clean",
    exposedRoutes: ["/"],
    exposedRoutePrefixes: ["/posts"],   // /posts/[slug] 動態路由
    publicRoutes: ["/"],
    publicRoutePrefixes: ["/posts"],
    defaultVisiblePath: "/",
    chatVariant: "normal",
  },
  // ...每條產品線一筆
];

export function findSpecialtyServerByHost(host?: string | null) {
  const normalized = host?.split(":")[0]?.toLowerCase() ?? "";
  return specialtyServerRegistry.find((s) => s.domains.includes(normalized)) ?? null;
}

幾個欄位值得說明設計動機:

  • exposedRoutes 是白名單不是黑名單。新頁面上線時,預設不會出現在任何 specialty 網域上,要露出得明寫——「不小心多露出一頁」比「忘了露出一頁」危險得多。
  • exposedRoutePrefixes 補動態路由的洞/posts/[slug] 無法枚舉成清單,用前綴比對放行。
  • passthroughRoutePrefixes 是例外閥。有些頁面(例如獨立的影音編輯工具)在檔案系統裡不住在該產品分支下,但要在該網域上原樣放行。現實不會永遠對齊理想的樹狀結構,與其扭曲目錄,不如給例外一個明確的宣告位置。
  • chatVariant:AI 助理在不同網域上載入不同的工具集和系統提示。網域的差異不只到頁面為止,會一路貫穿到 AI 行為。

第三步:middleware 解析

Middleware 是唯一讀這張表的地方。逐步拆解它對每個請求做的事:

1. 取出三個座標:host、locale、去語系路徑。

const host = request.headers.get("host")?.split(":")[0]?.toLowerCase() ?? "";
const locale = pathname.match(/^\/([a-z]{2}(?:-[A-Z]{2})?)(?=\/|$)/)?.[1]
  ?? routing.defaultLocale;
const pathWithoutLocale = getPathWithoutLocale(pathname); // "/zh-TW/tasks" → "/tasks"

語系永遠是路徑的第一段,所以先剝掉它,後面所有比對都在「去語系路徑」上進行——six 種語系共用同一套規則,不用乘上六倍的判斷。

2. 用 host 查表,得到這個請求的「人格」。

const server = findSpecialtyServerByHost(host);

查不到就是主網域,走一般流程。查到了,這筆設定接管後續所有決策。

3. 攔截「從錯的網域打內部路徑」。 任何人直接存取 /market/tasks 這種帶分支前綴的內部路徑——不管是在主網域上、還是在市集網域上多打了一層前綴——一律 redirect 到該網域的 defaultVisiblePath。這條規則保證內部路由結構不會洩漏成可分享的網址:不會有使用者收藏了帶內部前綴的連結,在某次目錄重構後集體 404。

4. 白名單檢查 + 路徑翻譯。 在正確網域上、路徑也乾淨時:

if (!isVisibleRouteForServer(server, pathWithoutLocale)) {
  // 不在 exposedRoutes / exposedRoutePrefixes 裡 → 送回預設頁
  return NextResponse.redirect(defaultPath);
}
if (server.routeMode === "clean") {
  // "/zh-TW/tasks" → "/zh-TW/market/tasks",網址列不變
  return NextResponse.rewrite(internalPath);
}

關鍵是 rewrite 和 redirect 的分工:合法的乾淨路徑用 rewrite 無聲翻譯到內部路徑,使用者看不到任何跳轉;不合法的路徑用 redirect 明著糾正。一個往裡翻、一個往外推,兩個路徑空間就此隔離。

5. 登入檢查。 requiresAuth 的網域上,沒有登入 cookie 的請求在這裡被攔去登入頁;publicRoutes 宣告的頁面除外。授權的「姿態」跟著網域走,頁面元件自己不用管。

6. 交棒給 i18n middleware。 沒觸發任何 rewrite/redirect 的請求,最後交給 next-intl 處理語系協商。順序重要:specialty 邏輯在前、i18n 在後,因為前者的比對已經自己處理了語系前綴。

還有一個容易忽略的配置——middleware 的 matcher 要排除靜態資源和 API 路由,否則每張圖片請求都會跑一次查表:

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

第四步:品牌文案與 SEO 也按 host 解析

路由解決後,剩下的問題是同一個頁面元件在不同網域上要顯示不同的品牌名。我們用第二張小表處理:

export const brandRegistry = [
  { key: "default", appName: "ProductName", domains: [] },
  { key: "market",  appName: "MarketBrand", domains: ["market.example.com"] },
];

export function applyBrandToText(value: string, brand: BrandConfig): string {
  if (brand.key === "default") return value;
  return value.replace(/\bProductName\b/g, brand.appName);
}

SEO metadata 工廠在 generateMetadata 階段做三件事:用 host 查品牌並替換 title/description 裡的品牌名;產生全語系的 hreflang alternates;以及按 host 決定 index 政策——preview 網域一律 noindex,不論頁面自己怎麼宣告。測試環境被搜尋引擎收錄是經典事故,在工廠層一次堵死,比要求每個頁面記得設定可靠。

延伸:企業客戶的自訂網域

這套架構原本是為自家產品線做的,但它的形狀恰好就是企業自訂網域(white-label)需要的形狀。企業客戶要的是:在自己的網域上、掛自己的品牌、只露出採購的功能子集、用自己設定的登入規則。對照上面的欄位——domains、品牌表、exposedRoutesrequiresAuth——每一項都已經是註冊表裡的一個欄位。開通一個企業網域等於:

  1. 客戶把 DNS CNAME 指向平台;
  2. 平台簽發該網域的憑證;
  3. 註冊表與品牌表各加一筆。

沒有新部署、沒有 fork。所有客戶跑在同一份持續演進的程式碼上。

要把這條路走到規模化,還有三件工程:註冊表從程式碼常數搬進資料庫,讓開通流程可以自助化(middleware 端加一層快取,避免每個請求打一次 DB);憑證簽發自動化(managed certificate 對 CNAME 網域的自動核發);以及登入 session 在第三方網域上的 cookie 隔離。這些都是在既有骨架上加東西——核心機制已經在自家五條產品線的生產流量上驗證了。

小結

回顧整個實作,用到的 Next.js 機制其實很基本:route group 隔 layout、資料夾分支定內部路徑、middleware 的 rewrite/redirect、generateMetadata。花心思的是把決策收斂到位:

  • 「哪個網域露出什麼」只存在於註冊表,一筆設定十來行;
  • 「怎麼翻譯路徑」只存在於 middleware,rewrite 往裡、redirect 往外;
  • 「品牌與 index 政策」只存在於 metadata 工廠。

新增一條產品線(或一個企業網域)不需要碰任何頁面元件。對我們來說,這個架構最實際的回報是回答問題的速度:「X 網域上露出了哪些功能?」——打開註冊表,答案就是那一筆。

在單一 Next.js 部署上服務多個網域