Next.js 中間件中的萬用字元子網域路由(多租戶 SaaS)
為什麼選擇基於子網域的多租戶架構
你的中間件在請求到達 Next.js 應用的那一刻就會觸發 — 在任何頁面渲染之前,在任何 API 路由執行之前。一個租戶在瀏覽器輸入 customer1.yourapp.com。另一個打開 customer2.yourapp.com。每個子網域都必須路由到隔離的儀表板、單獨的資料庫、不同的功能標誌 — 所有這一切來自同一個程式碼庫。大多數開發者會選擇基於路徑的路由(/tenant-slug/dashboard),結果繼承了一堆散落在二十個檔案中的 URL 重寫和驗證檢查。萬用字元子網域讓你可以在邊緣捕捉 *.yourapp.com,在中間件中提取租戶識別符,並在 React 掛載之前注入上下文。但有三個決策會決定你的實現能否擴展到 10,000 個租戶,還是會在 50 個租戶時就開始出現問題。
| 模式 | 示例 | 隔離性 | SEO | 使用者體驗 |
|---|---|---|---|---|
| 基於路徑 | app.com/tenant-a/dashboard |
低 | 共享網域權限 | 感覺像是共享平台 |
| 基於子網域 | tenant-a.app.com |
中 | 子網域權限 | 感覺像是專用應用 |
| 自訂網域 | app.tenant-a.com |
高 | 完整網域權限 | 感覺完全白標化 |
為什麼我們經常選擇子網域?這就是那個「恰到好處」的情況 — 適用於 95% 的情況。租戶獲得自己的品牌網址(acme.yourapp.com),而複雜性?可以控制。而且,當他們想升級到自訂網域時,你也不會卡住。對租戶來說感覺夠個人化,對你的技術棧來說不會變成魯布·戈德堡機器。
但這裡有個妙招:一開始使用子網域,然後提供自訂網域作為高級功能。透過 Next.js 中間件,這一切都可以流經一個簡潔的管道。說到效率,對吧?

架構概述
想像每個請求進入你的應用,就像在傳送帶上一樣。它遇到的第一件事?你的 Next.js 中間件。這個可靠的代碼段會提取子網域,找出它屬於哪個租戶,然後要麼重寫內部路徑,要麼用你的應用可以使用的請求頭標記它。輕而易舉!
請求:acme.yourapp.com/dashboard
↓
中間件:提取主機名 → 解析租戶 → 注入請求頭
↓
重寫:/dashboard → /[tenant]/dashboard(內部重寫)
↓
頁面:從參數或請求頭讀取租戶,提取租戶特定的資料
這裡真正發生的是一個魔法技巧 — 中間件重寫對使用者是不可見的。他們的瀏覽器仍然自豪地顯示 acme.yourapp.com/dashboard,而在幕後,Next.js 實際上路由到 /acme/dashboard。
目錄結構
以下是你的專案可能看起來的樣子:
├── middleware.ts
├── app/
│ ├── [tenant]/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── dashboard/
│ │ │ └── page.tsx
│ │ └── settings/
│ │ └── page.tsx
│ └── api/
│ └── tenant/
│ └── route.ts
├── lib/
│ ├── tenant.ts
│ └── middleware-utils.ts
設定萬用字元 DNS
在你寫一行代碼之前,你需要與 DNS 打交道。
Vercel
這個很直接:前往你的專案設定,添加一個萬用字元網域:
*.yourapp.com
yourapp.com
不用擔心,Vercel 會為你處理 SSL 憑證的繁重工作。截至 2026 年,你至少需要 Pro 方案(每個團隊成員每月 $20),因為免費方案不支援。
Cloudflare
Cloudflare 與萬用字元路由配合得很好。設定一個 A 記錄,如下所示:
類型:A
名稱:*
內容:<your-server-ip>
代理:是(橙色雲)
如果你在 Vercel 陣營,換成 CNAME 記錄:
類型:CNAME
名稱:*
內容:cname.vercel-dns.com
代理:僅限 DNS(灰色雲)
為什麼是灰色?Vercel 處理 SSL,Cloudflare 的代理在那裡不能很好地配合。中立是你的朋友。
自託管(Nginx)
server {
listen 80;
server_name *.yourapp.com;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
對於 SSL,你需要來自 Let's Encrypt 的萬用字元憑證。他們的 DNS-01 挑戰和 Certbot 外掛讓這比看起來更簡單。
構建中間件
好的,你想要代碼?這是經過實戰檢驗的中間件。我會讓你免去每個分號的榮耀,但相信我,它是黃金。
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
// 不應被視為租戶子網域的網域
const RESERVED_SUBDOMAINS = new Set([
'www',
'api',
'admin',
'app',
'mail',
'blog',
'docs',
'status',
]);
const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN || 'yourapp.com';
export const config = {
matcher: [
/*
* 匹配除以下之外的所有路徑:
* - _next/static(靜態檔案)
* - _next/image(圖像最佳化)
* - favicon.ico
* - 公開資料夾檔案
*/
'/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*)',
],
};
export default async function middleware(req: NextRequest) {
const url = req.nextUrl;
const hostname = req.headers.get('host') || '';
// 移除連接埠以便本地開發
const currentHost = hostname.replace(/:\d+$/, '');
// 檢查這是否是根網域或 www
if (
currentHost === ROOT_DOMAIN ||
currentHost === `www.${ROOT_DOMAIN}` ||
currentHost === 'localhost'
) {
// 這是行銷網站 / 登陸頁面
return NextResponse.next();
}
// 提取子網域
let tenant: string | null = null;
if (currentHost.endsWith(`.${ROOT_DOMAIN}`)) {
const subdomain = currentHost.replace(`.${ROOT_DOMAIN}`, '');
if (RESERVED_SUBDOMAINS.has(subdomain)) {
return NextResponse.next();
}
tenant = subdomain;
} else {
// 這可能是一個自訂網域
tenant = await resolveCustomDomain(currentHost);
}
if (!tenant) {
// 未知網域 — 重定向到主網站
return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
}
// 重寫到租戶特定的路徑
const tenantUrl = new URL(`/${tenant}${url.pathname}`, req.url);
tenantUrl.search = url.search;
const response = NextResponse.rewrite(tenantUrl);
// 將租戶資訊注入為下游可以消費的請求頭
response.headers.set('x-tenant-slug', tenant);
response.headers.set('x-tenant-domain', currentHost);
return response;
}
async function resolveCustomDomain(domain: string): Promise<string | null> {
try {
// 在生產中,激進地快取這個
const res = await fetch(
`${process.env.INTERNAL_API_URL}/api/domains/resolve?domain=${domain}`,
{
headers: { Authorization: `Bearer ${process.env.INTERNAL_API_KEY}` },
next: { revalidate: 300 },
}
);
if (!res.ok) return null;
const data = await res.json();
return data.tenantSlug || null;
} catch {
return null;
}
}
Matcher 模式至關重要
你想要速度?這個 config.matcher 正規表達式是你的最好朋友。沒有它,每個請求都會拖著中間件一起走 — 靜態資產、圖像,所有的一切。那是一條通往 200ms+ 額外延遲的單向票。沒人想要那樣。看看這個:/((?!_next/static|_next/image|favicon\\.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js)$).*). 僅此一項就將我們的 P95 延遲降低了 40%!是的,這真的很重要。
為什麼選擇重寫而不是重定向?
重寫不會搞亂使用者在瀏覽器中看到的內容 — 我們談論的是一帆風順。重定向?那就像是要求你的使用者相信你會把他們帶到正確的地點 — 另一個 URL 在方塊裡。堅持使用 rewrite() 以獲得無戲劇性的使用者體驗。

租戶解析策略
上面的中間件像專家一樣從子網域中提取租戶。但租戶存在?必須確認那個。不同的策略適用於不同的情況。
策略 1:中間件中的資料庫查詢
這裡有個問題:Next.js 中間件在邊緣運行時上執行,這意味著要說再見許多舒適的 Node.js API。
// 使用 Prisma Accelerate 或 @prisma/client/edge
import { PrismaClient } from '@prisma/client/edge';
const prisma = new PrismaClient();
async function resolveTenant(slug: string) {
return prisma.tenant.findUnique({
where: { slug },
select: { id: true, slug: true, plan: true },
});
}
策略 2:KV 存儲查詢
這是我的首選。將你的 slug 存儲在 KV 存儲中,如 Vercel KV、Upstash Redis 或 Cloudflare KV。邊緣查詢在 1-5ms 內意味著可以忽略的延遲。
import { kv } from '@vercel/kv';
async function resolveTenant(slug: string) {
const tenant = await kv.get(`tenant:${slug}`);
return tenant as TenantConfig | null;
}
策略 3:靜態允許列表
小規模運營?靜態允許列表可以是你的朋友 — 構建時間 JSON 檔案保持緊湊和零網路。
import tenants from './tenants.json';
const tenantMap = new Map(tenants.map(t => [t.slug, t]));
function resolveTenant(slug: string) {
return tenantMap.get(slug) || null;
}
通過 ISR 或 webhook 重建以動態處理新租戶。
多租戶資料庫設計
這是事情變得認真的地方。有兩條主要的路徑可以選擇。
帶有租戶 ID 的共享資料庫
添加一個 tenantId 欄,就像它在打折一樣:
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_projects_tenant ON projects(tenant_id);
是的,PostgreSQL RLS 是你的保險單:
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant')::uuid);
當你設定上下文時,生活變得更容易:
await db.execute(sql`SELECT set_config('app.current_tenant', ${tenantId}, true)`);
每個租戶一個資料庫
想要超高級隔離,適合合規要求,就像手套一樣貼合?每個租戶一個資料庫是正確的方法。Neon 的分支和 PlanetScale 的酷炫按資料庫定價使這成為可行的,甚至對於 2026 年的小財富來說。
自訂網域支援
這是部署和虛榮心相遇的地方。租戶需要他們的 CNAME 記錄自訂網域指向你。而 SSL?每個被點擊的連結都需要因為有 HTTPS 而閃耀。
Vercel 自訂網域 API
Vercel 的 API 讓這變得盡可能無痛:
async function addCustomDomain(domain: string, tenantId: string) {
const response = await fetch(
`https://api.vercel.com/v10/projects/${process.env.VERCEL_PROJECT_ID}/domains`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VERCEL_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: domain }),
}
);
if (response.ok) {
// 存儲網域到租戶的映射
await db.customDomain.create({
data: { domain, tenantId, verified: false },
});
}
return response.json();
}
一旦 DNS 排列好,Vercel 就會處理 SSL。查看他們的 Platforms Starter Kit — 它是一個模型實現。
Cloudflare for SaaS
這對於花哨的自訂 DNS 管理器來說是理想的,因為 Cloudflare 的「SSL for SaaS」拋入了 SSL 供應和代理作為獎勵。
效能和快取
在處理請求時,每一奈秒都很重要。技巧是加速租戶解析。
快取租戶解析
你會透過快取租戶查詢來拯救這一天(和伺服器):
import { LRUCache } from 'lru-cache';
const tenantCache = new LRUCache<string, TenantConfig>({
max: 10000,
ttl: 1000 * 60 * 5, // 5 分鐘
});
async function resolveTenantCached(slug: string): Promise<TenantConfig | null> {
const cached = tenantCache.get(slug);
if (cached) return cached;
const tenant = await resolveTenantFromDB(slug);
if (tenant) {
tenantCache.set(slug, tenant);
}
return tenant;
}
不過請記住,記憶體內快取不適合邊緣運行時。使用類似 Upstash Redis 的東西 — 這就是他們的專長。
ISR 和多租戶
ISR 喜歡多租戶,因為它很聰明。每個租戶獲得一個快取版本,因為從 /dashboard 重寫到 /acme/dashboard 而獨特。沒有額外的配置,只是沐浴在 Next.js 魔法的榮耀中。
部署配置
決策,決策。讓我們比較部署選項:
| 功能 | Vercel Pro | Cloudflare Pages | 自託管(Docker) |
|---|---|---|---|
| 萬用字元網域 | ✅ | ✅ | ✅(手動) |
| 自訂網域 API | ✅ | ✅ | ❌(手動) |
| 邊緣中間件 | ✅ | ✅ | ❌(僅 Node) |
| 自動 SSL | ✅ | ✅ | ⚠️ Let's Encrypt |
| 定價 | 每人每月 $20 + 使用量 | 每月 $5 + 使用量 | 每月伺服器 $50-200 |
| 最大自訂網域 | 50+ | 無限制 | 無限制 |
對於閃亮和快速啟動的發布,Vercel Pro 是你的票券。但當這些數字攀升時 — 更多使用者、更多請求 — Cloudflare Pages 或自託管選項為你提供靈活性和經濟性。
我們已經使用每種方法導航過多租戶奇蹟,所以如果你在思考下一步,我們的Next.js 開發能力可能是你的指南。
安全考量
多租戶超酷,直到它不酷為止。每個租戶都期望他們的資料安全 — 沒有洩露,沒有滑動。
租戶隔離檢查清單
- 按租戶 ID 過濾:永遠不要僅相信 URL。後端檢查也很重要。
- PostgreSQL RLS:就像有一個安全詳情在你的資料庫 24/7 上班。
- 清理子網域:不要滑動 — 僅允許
[a-z0-9-]。避免子網域接管。 - 按租戶限流:API 節流的動態請求頭可以拯救你的培根。
- 記錄、稽核、檢查:每個寫入操作都應該說「我抓到你了」。信心培養信任。
// 在中間件中驗證租戶 slug
const VALID_SLUG = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
if (!VALID_SLUG.test(subdomain)) {
return NextResponse.redirect(new URL(`https://${ROOT_DOMAIN}`));
}
CORS 和多租戶
你的 API 為許多子網域提供服務,你需要 CORS 不要發脾氣:
// app/api/[...route]/route.ts
export async function GET(req: NextRequest) {
const origin = req.headers.get('origin') || '';
const isValidOrigin =
origin.endsWith(`.${ROOT_DOMAIN}`) ||
await isCustomDomain(origin.replace('https://', ''));
const headers = new Headers();
if (isValidOrigin) {
headers.set('Access-Control-Allow-Origin', origin);
headers.set('Access-Control-Allow-Credentials', 'true');
}
// ... 處理器的其餘部分
}
在本地測試多租戶中間件
如果本地開發不支援子網域,頭痛就會到來。以下是你重新做夢的方式。
選項 1:編輯 `/etc/hosts`
# /etc/hosts
127.0.0.1 yourapp.local
127.0.0.1 acme.yourapp.local
127.0.0.1 globex.yourapp.local
然後更新你的中間件以在開發過程中識別 .yourapp.local:
const ROOT_DOMAIN = process.env.NODE_ENV === 'development'
? 'yourapp.local:3000'
: 'yourapp.com';
選項 2:使用 nip.io 或 sslip.io
這些服務提取出魔法 IP 映射:
acme.127.0.0.1.nip.io → 127.0.0.1
globex.127.0.0.1.nip.io → 127.0.0.1
簡單,不需要編輯主機。
選項 3:使用自訂子網域的本地隧道
使用 ngrok(或類似的)快速隧道:
ngrok http 3000 --hostname=*.your-dev-domain.ngrok.io
測試不應該比真實世界的情景更難。
撰寫整合測試
import { describe, it, expect } from 'vitest';
import { middleware } from './middleware';
import { NextRequest } from 'next/server';
describe('租戶中間件', () => {
it('將子網域請求重寫到租戶路徑', async () => {
const req = new NextRequest('https://acme.yourapp.com/dashboard');
const res = await middleware(req);
expect(res.headers.get('x-middleware-rewrite')).toContain('/acme/dashboard');
expect(res.headers.get('x-tenant-slug')).toBe('acme');
});
it('傳遞根網域請求', async () => {
const req = new NextRequest('https://yourapp.com/');
const res = await middleware(req);
expect(res.headers.get('x-middleware-rewrite')).toBeNull();
});
it('拒絕無效的子網域字元', async () => {
const req = new NextRequest('https://acme--evil.yourapp.com/');
const res = await middleware(req);
expect(res.status).toBe(307); // 重定向
});
});
從更大的角度考慮?無論是在 Next.js 或 Astro 之間的選擇,都可以考慮跳上我們的無頭 CMS 層洞察旅程。
記住,探索意味著提問、適應和成長。如果你的目標是頂級合作夥伴關係,請聯絡我們進行頭腦風暴會議或深入了解我們的定價。