ワイルドカードサブドメイン Next.js ミドルウェア for マルチテナント SaaS
サブドメインベースのマルチテナントの理由
分解してみましょう。SaaS アプリケーションでマルチテナントルーティングを扱う際、一般的には 3 つのオプションがあります:
| パターン | 例 | 分離性 | SEO | ユーザー体験 |
|---|---|---|---|---|
| パスベース | app.com/tenant-a/dashboard |
低 | 共有ドメイン権限 | 共有プラットフォームに見える |
| サブドメインベース | tenant-a.app.com |
中程度 | サブドメイン権限 | 専用アプリに見える |
| カスタムドメイン | app.tenant-a.com |
高 | フルドメイン権限 | 完全ホワイトラベル |
なぜ多くの場合サブドメインを選ぶのか?それは「ちょうど良い」状況です。ほぼすべてのケース (95%) に適しています。テナントは独自ブランドの URL (acme.yourapp.com) を取得でき、複雑さは管理可能です。さらに、後でカスタムドメインへのアップグレードを希望しても困りません。テナントにとっては十分にパーソナルで、テック スタックがルーブ ゴールドバーグ マシンになることもありません。
しかし、ここが肝心です: サブドメインから始めて、カスタムドメインをプレミアム機能として提供します。Next.js ミドルウェアを使えば、これらすべてが 1 つのパイプラインを通じて流れます。効率的ですよね?

アーキテクチャの概要
すべてのリクエストがコンベアベルトに乗ってアプリに入ると想像してください。最初に遭遇するものは何でしょうか?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 の設定
コードを 1 行書く前に、DNS と向き合う必要があります。
Vercel
これは簡単です: プロジェクト設定に移動して、ワイルドカードドメインを追加します:
*.yourapp.com
yourapp.com
そして心配しないでください。Vercel は SSL 証明書の重い作業をしてくれます。2025 年現在、ホビープランは対応していないため、少なくとも Pro プラン (チームの 1 メンバーあたり月額 $20) が必要です。
Cloudflare
Cloudflare はワイルドカードルーティングと相性が良いです。次のように A レコードをセットアップします:
Type: A
Name: *
Content: <your-server-ip>
Proxy: Yes (オレンジ色のクラウド)
Vercel グループに属している場合は、CNAME レコードと交換します:
Type: CNAME
Name: *
Content: cname.vercel-dns.com
Proxy: DNS only (グレーのクラウド)
なぜグレーなのか?Vercel は SSL を処理し、Cloudflare のプロキシはそこではうまく機能しません。ニュートラルはあなたの友人です。
Self-Hosted (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
* - public フォルダ ファイル
*/
'/((?!_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;
}
}
マッチャーパターンがすべてです
スピードが欲しいですか?その 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 ストア参照
これが私のお気に入りです。Vercel KV、Upstash Redis、または Cloudflare KV などの 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 を使用して再構築し、新しいテナントを動的に処理します。
マルチテナントのためのデータベース設計
ここは物事が真摯になるところです。2 つの主要パスから選択します:
共有データベースとテナント 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 のクールなデータベースごとの価格設定により、これは 2025 年でも小さな財産でも実行可能になります。
カスタムドメインのサポート
ここは展開とバニティが満たされる場所です。テナントは 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();
}
Vercel は DNS が並ぶと SSL を処理します。彼らのプラットフォーム スターター キットをチェックしてください。それはモデル実装です。
Cloudflare for SaaS
これは、Cloudflare の「SSL for SaaS」が SSL プロビジョニングとプロキシを追加するため、派手なカスタム DNS マネージャーに最適です。
パフォーマンスとキャッシング
リクエストを処理するとき、すべてのナノ秒がカウントされます。コツはテナント解決のスピードです。
キャッシュテナント解決
テナント参照をキャッシュすることで、その日と (そしてサーバーを) 保存します:
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 | Self-Hosted (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 スロットルの動的ヘッダーはあなたのベーコンを保存できます。
- ログ、監査、確認: すべての書き込み操作は「了解」と言うべきです。自信は信頼を授けます。
// ミドルウェアでテナント スラッグを検証
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 レイヤーの洞察 の旅に乗り出すことを検討してください。
探索とは、質問し、適応し、成長することを意味することを覚えてください。脳会議のためにご連絡いただくか、トップティア パートナーシップを目指している場合は、価格 についてさらに掘り下げてください。