0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · Lesson

Per-Tenant Theming and Feature Flags

Load tenant-specific branding and gated features at request time without redeploys.

Per-Tenant Theming and Feature Flags is a free Next.js 15 Fullstack (App Router + Server Actions) lesson on CoddyKit — lesson 3 of 4. You can read the complete lesson below for free — then practise it hands-on in the browser with a built-in code editor and a 24/7 AI tutor. It is part of the Next.js 15 Fullstack (App Router + Server Actions) learning path, one of 4 lessons in the course, and your progress syncs across the web and the CoddyKit app.

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 null or 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 via getTenantConfig).
  • 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.
  • getTenantConfig uses React cache() to resolve the full TenantConfig from 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.

Frequently asked questions

Is the “Per-Tenant Theming and Feature Flags” lesson free?

Yes — the full text of “Per-Tenant Theming and Feature Flags” is free to read here on the web, and the Next.js 15 Fullstack (App Router + Server Actions) course includes 4 lessons in total. To practise it interactively (a built-in code editor and a 24/7 AI tutor) and unlock the rest of the Next.js 15 Fullstack (App Router + Server Actions) course, upgrade to CoddyKit PRO.

What will I learn in “Per-Tenant Theming and Feature Flags”?

Load tenant-specific branding and gated features at request time without redeploys. You practise Next.js 15 Fullstack (App Router + Server Actions) with hands-on code you run directly in the browser, and a 24/7 AI tutor answers your questions as you work through the lesson.

Do I need any experience to start Next.js 15 Fullstack (App Router + Server Actions)?

No prior experience is required. Next.js 15 Fullstack (App Router + Server Actions) on CoddyKit is structured for beginners through advanced learners; this is — lesson 3 of 4, so you can start here or from the beginning and move at your own pace.

How long does the “Per-Tenant Theming and Feature Flags” lesson take?

Most CoddyKit lessons take about 5–10 minutes. Each one is bite-sized and interactive, so you make steady progress and pick up exactly where you left off across the web and the app.

Can I write and run code in this Next.js 15 Fullstack (App Router + Server Actions) lesson?

Yes. Every Next.js 15 Fullstack (App Router + Server Actions) lesson includes a built-in code editor, so you write and run real code right in your browser and get instant AI feedback — no local setup required.

All lessons in this course

  1. Subdomain and Path-Based Tenant Resolution
  2. Row-Level Tenant Data Isolation Patterns
  3. Per-Tenant Theming and Feature Flags
  4. Usage Metering and Subscription Enforcement
← Back to Next.js 15 Fullstack (App Router + Server Actions)