按租户设置主题与功能开关
在请求时加载租户专属品牌和受控功能,无需重新部署。
按租户设置主题与功能开关 是 CoddyKit 上的免费 Next.js 15 Fullstack (App Router + Server Actions) 课时。 这是第 3 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Next.js 15 Fullstack (App Router + Server Actions) 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。
本课时的部分内容尚未翻译,以英文显示。
What Is Per-Tenant Theming?
In a multi-tenant SaaS application, every customer (tenant) often expects the product to feel like their own. Per-tenant theming means loading a different color palette, logo, typography, or layout at request time — without shipping a new build.
- White-labeling: the tenant's brand replaces yours entirely.
- Accent overrides: a shared UI with per-tenant primary colors and fonts.
- Feature flags: certain UI elements or API routes are enabled only for specific tenants based on their plan or configuration.
Next.js 15 App Router is ideal for this because every request passes through Server Components and middleware, giving you a natural hook to resolve tenant context before anything renders.
Resolving the Tenant at the Edge
The first step is identifying which tenant is making the request. The two most common strategies are:
- Subdomain routing:
acme.app.com→ tenant slug =acme - Custom domain mapping:
dashboard.acme.com→ look up tenant by host header
Next.js 15 middleware runs at the edge before any Server Component, making it the right place to resolve the tenant and forward that context downstream via request headers.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(req: NextRequest) {
const host = req.headers.get('host') ?? '';
// Extract subdomain: "acme.app.com" -> "acme"
const subdomain = host.split('.')[0];
const tenantSlug = subdomain !== 'www' && subdomain !== 'app' ? subdomain : 'default';
const res = NextResponse.next();
// Forward tenant slug to Server Components via a custom header
res.headers.set('x-tenant-slug', tenantSlug);
return res;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};Defining the Tenant Config Schema
Before fetching tenant data, define a clear TypeScript type that describes everything a tenant can customize. This becomes your contract between the database and the UI.
- Branding: primary color, logo URL, font family.
- Feature flags: a record of string keys to booleans, so you can add new flags without schema changes.
- Plan tier: used to gate entire feature sets.
// lib/tenant/types.ts
export type PlanTier = 'free' | 'pro' | 'enterprise';
export interface TenantBranding {
primaryColor: string; // e.g. "#6366F1"
logoUrl: string;
fontFamily: string; // e.g. "Inter"
companyName: string;
}
export interface TenantConfig {
id: string;
slug: string;
plan: PlanTier;
branding: TenantBranding;
/** Map of feature-flag keys to enabled status */
features: Record<string, boolean>;
}
// Helper to check a flag safely
export function isFeatureEnabled(
config: TenantConfig,
flag: string
): boolean {
return config.features[flag] === true;
}Fetching Tenant Config Server-Side
With the slug forwarded via headers, any Server Component or Server Action can resolve the full TenantConfig from your database or a fast cache (Redis, Vercel KV, etc.).
Use React's cache() to deduplicate the lookup within a single request — the function is called many times across nested layouts and pages, but the DB query runs only once.
// lib/tenant/get-tenant-config.ts
import { cache } from 'react';
import { headers } from 'next/headers';
import { db } from '@/lib/db'; // your Drizzle / Prisma client
import type { TenantConfig } from './types';
export const getTenantConfig = cache(async (): Promise<TenantConfig> => {
const headerList = await headers();
const slug = headerList.get('x-tenant-slug') ?? 'default';
const row = await db.query.tenants.findFirst({
where: (t, { eq }) => eq(t.slug, slug),
columns: { id: true, slug: true, plan: true, branding: true, features: true },
});
if (!row) {
throw new Error(`Tenant not found: ${slug}`);
}
return row as TenantConfig;
});Injecting CSS Variables for Branding
The cleanest way to apply per-tenant colors and fonts is via CSS custom properties set on the root element. Your Tailwind or plain CSS classes reference these variables — the values change per tenant, but the class names never change.
In the root layout, fetch tenant config and emit an inline <style> tag. Because this is a Server Component, there is zero client JavaScript overhead.
// app/layout.tsx
import { getTenantConfig } from '@/lib/tenant/get-tenant-config';
import type { ReactNode } from 'react';
export default async function RootLayout({ children }: { children: ReactNode }) {
const tenant = await getTenantConfig();
const { primaryColor, fontFamily } = tenant.branding;
const cssVars = [
`--color-primary: ${primaryColor};`,
`--font-sans: '${fontFamily}', sans-serif;`,
].join('\n');
return (
<html lang="en">
<head>
<style>{`:root { ${cssVars} }`}</style>
</head>
<body style={{ fontFamily: 'var(--font-sans)' }}>
{children}
</body>
</html>
);
}Rendering the Tenant Logo and Name
With getTenantConfig memoized via cache(), you can call it freely inside any nested Server Component. The result is the same object reused from the first call — no extra DB round-trips.
A shared TenantHeader Server Component reads the config and renders the tenant's logo and company name without any props drilling from parent layouts.
// components/tenant-header.tsx
import Image from 'next/image';
import { getTenantConfig } from '@/lib/tenant/get-tenant-config';
export default async function TenantHeader() {
const { branding } = await getTenantConfig();
return (
<header className="flex items-center gap-3 px-6 py-4 border-b">
<Image
src={branding.logoUrl}
alt={branding.companyName}
width={120}
height={32}
priority
/>
<span className="text-lg font-semibold text-[var(--color-primary)]">
{branding.companyName}
</span>
</header>
);
}Feature Flag Gating in Server Components
Feature flags stored in the tenant config let you conditionally render entire sections of UI on the server, so disabled features never reach the client bundle at all.
- Call
isFeatureEnabled(config, 'analytics_dashboard')inside a Server Component. - If the flag is off, return
nullor an upgrade prompt — the component code is still in your bundle but the output is omitted. - This is safer than client-side gating, where a determined user could inspect the JS.
// app/dashboard/page.tsx
import { getTenantConfig, isFeatureEnabled } from '@/lib/tenant';
import AnalyticsDashboard from '@/components/analytics-dashboard';
import UpgradeBanner from '@/components/upgrade-banner';
export default async function DashboardPage() {
const config = await getTenantConfig();
const hasAnalytics = isFeatureEnabled(config, 'analytics_dashboard');
return (
<main className="p-8">
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
{hasAnalytics ? (
<AnalyticsDashboard />
) : (
<UpgradeBanner
message="Upgrade to Pro to unlock Analytics."
plan={config.plan}
/>
)}
</main>
);
}Protecting API Routes with Feature Flags
UI gating is not enough — a determined user can call your API route directly. Always enforce feature flags inside the Route Handler or Server Action as well.
Create a reusable guard helper that resolves tenant config and throws a typed error if the requested feature is disabled. This keeps the enforcement logic in one place.
// lib/tenant/require-feature.ts
import { getTenantConfig, isFeatureEnabled } from './get-tenant-config';
export class FeatureDisabledError extends Error {
constructor(flag: string) {
super(`Feature "${flag}" is not enabled for this tenant.`);
this.name = 'FeatureDisabledError';
}
}
export async function requireFeature(flag: string): Promise<void> {
const config = await getTenantConfig();
if (!isFeatureEnabled(config, flag)) {
throw new FeatureDisabledError(flag);
}
}
// Usage inside a Route Handler:
// app/api/analytics/route.ts
import { requireFeature, FeatureDisabledError } from '@/lib/tenant/require-feature';
import { NextResponse } from 'next/server';
export async function GET() {
try {
await requireFeature('analytics_dashboard');
} catch (e) {
if (e instanceof FeatureDisabledError) {
return NextResponse.json({ error: e.message }, { status: 403 });
}
throw e;
}
// ... return analytics data
return NextResponse.json({ data: [] });
}Server Actions and Feature Gating
Server Actions are invoked directly from Client Components and can also be gated. Because Server Actions run on the server, requireFeature works identically — the tenant context is resolved from the request headers that Next.js forwards automatically.
// app/actions/export-report.ts
'use server';
import { requireFeature, FeatureDisabledError } from '@/lib/tenant/require-feature';
import { getTenantConfig } from '@/lib/tenant/get-tenant-config';
export async function exportReport(format: 'csv' | 'pdf') {
// Enforce feature flag before any expensive work
await requireFeature('report_export');
const config = await getTenantConfig();
// PDF export only available on enterprise plan
if (format === 'pdf' && config.plan !== 'enterprise') {
throw new Error('PDF export requires an Enterprise plan.');
}
// ... generate and return the report
return { url: `https://cdn.example.com/reports/${config.id}/report.${format}` };
}Caching Tenant Config Efficiently
Database lookups on every request would be too slow. Use a two-layer caching strategy:
- Layer 1 — React
cache(): deduplicates within a single request (already applied viagetTenantConfig). - Layer 2 — External cache (Redis / Vercel KV): stores the resolved config for N minutes so subsequent requests skip the DB entirely.
When a tenant updates their branding in the admin panel, invalidate their cache key via a Server Action or webhook handler.
// lib/tenant/tenant-cache.ts
import { kv } from '@vercel/kv'; // or ioredis
import { db } from '@/lib/db';
import type { TenantConfig } from './types';
const TTL_SECONDS = 300; // 5 minutes
export async function getOrFetchTenantConfig(slug: string): Promise<TenantConfig> {
const cacheKey = `tenant:${slug}`;
const cached = await kv.get<TenantConfig>(cacheKey);
if (cached) return cached;
const row = await db.query.tenants.findFirst({
where: (t, { eq }) => eq(t.slug, slug),
});
if (!row) throw new Error(`Tenant not found: ${slug}`);
await kv.set(cacheKey, row, { ex: TTL_SECONDS });
return row as TenantConfig;
}
/** Call after admin updates tenant config */
export async function invalidateTenantCache(slug: string): Promise<void> {
await kv.del(`tenant:${slug}`);
}Exposing Minimal Config to the Client
Sometimes a Client Component needs to know the tenant's primary color or whether a feature is enabled — for example, to style an interactive chart or conditionally render a button.
Never serialize the full TenantConfig (which may contain sensitive plan or pricing data) to the client. Instead, create a trimmed client-safe view and pass it as a prop from a Server Component, or expose it via a lightweight context.
// lib/tenant/client-config.ts
export interface TenantClientConfig {
primaryColor: string;
logoUrl: string;
companyName: string;
enabledFeatures: string[]; // only the keys that ARE enabled
}
// app/providers.tsx (Client Component wrapping the app shell)
'use client';
import { createContext, useContext } from 'react';
import type { TenantClientConfig } from '@/lib/tenant/client-config';
const TenantContext = createContext<TenantClientConfig | null>(null);
export function TenantProvider({
config,
children,
}: {
config: TenantClientConfig;
children: React.ReactNode;
}) {
return <TenantContext.Provider value={config}>{children}</TenantContext.Provider>;
}
export function useTenant(): TenantClientConfig {
const ctx = useContext(TenantContext);
if (!ctx) throw new Error('useTenant must be used inside TenantProvider');
return ctx;
}Knowledge Check: Where Should Feature Flags Be Enforced?
A colleague argues that checking feature flags only in Client Components is sufficient, since the UI will simply hide disabled features. What is the primary flaw in this approach?
Lesson Recap: Per-Tenant Theming and Feature Flags
You now have a complete request-time multi-tenancy system in Next.js 15. Here is what you built:
- Middleware extracts the tenant slug from the subdomain or host header and forwards it via a custom request header.
getTenantConfiguses Reactcache()to resolve the fullTenantConfigfrom the database exactly once per request, with an external Redis/KV layer for cross-request caching.- CSS variables injected in the root layout apply per-tenant colors and fonts with zero client JS.
- Feature flags are checked on the server — in Server Components, Route Handlers, and Server Actions — so disabled features are invisible and unreachable.
- Only a minimal client-safe config is serialized to the client, protecting sensitive plan and pricing data.
This architecture scales cleanly: adding a new feature flag requires only a database column change and a single isFeatureEnabled call — no redeploys, no per-tenant builds.
常见问题解答
「按租户设置主题与功能开关」课时是免费的吗?
是的 — 「按租户设置主题与功能开关」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Next.js 15 Fullstack (App Router + Server Actions) 课程的其余内容,请升级到 CoddyKit PRO。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。
「按租户设置主题与功能开关」这节课中我会学到什么?
在请求时加载租户专属品牌和受控功能,无需重新部署。 你通过在浏览器中直接运行的动手代码来练习 Next.js 15 Fullstack (App Router + Server Actions),全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 Next.js 15 Fullstack (App Router + Server Actions) 需要有经验吗?
无需任何先前经验。CoddyKit 上的 Next.js 15 Fullstack (App Router + Server Actions) 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 3 节课,共 4 节。
「按租户设置主题与功能开关」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 Next.js 15 Fullstack (App Router + Server Actions) 课中编写并运行代码吗?
能。每节 Next.js 15 Fullstack (App Router + Server Actions) 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。
此课程中的所有课时
- 基于子域名与路径的租户解析
- 行级租户数据隔离模式
- 按租户设置主题与功能开关
- 用量计量与订阅限制执行