Thèmes et indicateurs de fonctionnalité par locataire
Chargez à la demande l’identité visuelle et les fonctionnalités activées propres à chaque locataire, sans nouveau déploiement.
Thèmes et indicateurs de fonctionnalité par locataire est une leçon Next.js 15 Fullstack (App Router + Server Actions) gratuite sur CoddyKit. Ceci est la leçon 3 sur 4. Tu peux lire la leçon complète ci-dessous gratuitement — puis la pratiquer en direct dans le navigateur avec un éditeur de code intégré et un tuteur IA 24/7. Elle fait partie du parcours d'apprentissage Next.js 15 Fullstack (App Router + Server Actions), et ta progression se synchronise sur le web et l'application CoddyKit. Le cours Next.js 15 Fullstack (App Router + Server Actions) comprend 4 leçons au total.
Certaines parties de cette leçon n'ont pas encore été traduites et s'affichent en anglais.
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.
Apprends TypeScript avec un tuteur IA — gratuit
Écris et exécute du vrai code dans ton navigateur, obtiens de l'aide instantanée d'un tuteur IA disponible 24h/24, et reprends là où tu t'es arrêté sur le web ou dans l'app.
- Cours
- 22
- Leçons
- 88
Questions Fréquemment Posées
La leçon « Thèmes et indicateurs de fonctionnalité par locataire » est-elle gratuite ?
Oui — le texte complet de « Thèmes et indicateurs de fonctionnalité par locataire » est gratuit à lire ici sur le web. Pour la pratiquer de manière interactive (un éditeur de code intégré et un tuteur IA 24/7) et déverrouiller le reste du cours Next.js 15 Fullstack (App Router + Server Actions), passe à CoddyKit PRO. Le cours Next.js 15 Fullstack (App Router + Server Actions) comprend 4 leçons au total.
Qu'est-ce que j'apprendrai dans « Thèmes et indicateurs de fonctionnalité par locataire » ?
Chargez à la demande l’identité visuelle et les fonctionnalités activées propres à chaque locataire, sans nouveau déploiement. Tu pratiques Next.js 15 Fullstack (App Router + Server Actions) avec du code pratique que tu exécutes directement dans le navigateur, et un tuteur IA 24/7 répond à tes questions au fur et à mesure que tu avances dans la leçon.
Dois-je avoir de l'expérience pour commencer Next.js 15 Fullstack (App Router + Server Actions) ?
Aucune expérience préalable n'est requise. Next.js 15 Fullstack (App Router + Server Actions) sur CoddyKit est structuré pour les débutants jusqu'aux apprenants avancés, donc tu peux commencer ici ou depuis le début et avancer à ton rythme. Ceci est la leçon 3 sur 4.
Combien de temps prend la leçon « Thèmes et indicateurs de fonctionnalité par locataire » ?
La plupart des leçons CoddyKit prennent environ 5–10 minutes. Chacune est courte et interactive, tu progresses régulièrement et tu repiques exactement où tu t'es arrêté sur le web et l'app.
Peux-tu écrire et exécuter du code dans cette leçon Next.js 15 Fullstack (App Router + Server Actions) ?
Oui. Chaque leçon Next.js 15 Fullstack (App Router + Server Actions) inclut un éditeur de code intégré, tu écris et exécutes du vrai code directement dans ton navigateur et tu reçois des retours IA instantanés — aucune configuration locale requise.
Toutes les leçons de ce cours
- Résolution des locataires par sous-domaine et par chemin
- Modèles d’isolation des données des locataires au niveau des lignes
- Thèmes et indicateurs de fonctionnalité par locataire
- Mesure de l’utilisation et application des abonnements