React Email13 min read

Multi-Tenant Email Templates with Next.js and React Email: Per-Brand Customization at Scale

B2B SaaS apps need to send emails that look like they come from the customer's brand, not yours. Build a dynamic, multi-tenant email template system with React Email, tenant config schemas, preview tooling, and caching.

R

React Emails Pro

March 8, 2026

Your B2B SaaS sends transactional emails on behalf of your customers. Password resets, invoices, welcome messages — all flowing through your infrastructure. But they arrive in the inbox looking like they came from your company, not theirs. Your customer's end users see an unfamiliar logo, wrong colors, and a generic sender name. Trust evaporates before the email is even opened.

Multi-tenant email branding isn't a nice-to-have. It's table stakes for any B2B platform that sends emails on behalf of customers. The architecture to do it well, though, is rarely discussed. Most teams bolt it on after the first enterprise deal demands white-labeling, and the result is a mess of conditionals scattered across templates.

This guide walks through a clean, scalable architecture for per-tenant branded emails using React Email and Next.js — from the configuration schema to caching rendered output.

68%

B2B buyers expect white-labeled experiences

Salesforce State of the Connected Customer, 2024

3x

Higher engagement with branded emails

vs. generic platform-branded transactional emails

45%

Enterprise deals require white-label email

Common blocker in security questionnaires and procurement


The tenant configuration schema

Everything starts with a well-typed configuration object. This is the contract between your database and your email templates. Get it right early — retrofitting new fields into a live multi-tenant system is painful.

lib/email/tenant-config.ts
export interface TenantBrandingConfig {
  // Identity
  tenantId: string;
  companyName: string;
  supportEmail: string;

  // Visual branding
  logoUrl: string;
  logoWidth: number;      // pixels — email clients need explicit dimensions
  logoHeight: number;
  primaryColor: string;   // hex, e.g. "#4F46E5"
  secondaryColor: string; // hex, used for secondary buttons/links
  accentColor: string;    // hex, used for highlights and badges

  // Typography
  headingFont: string;    // web-safe font stack for headings
  bodyFont: string;       // web-safe font stack for body text

  // Custom content
  footerText: string;     // legal / compliance copy
  footerLinks: Array<{ label: string; url: string }>;

  // Sending
  fromName: string;       // "Acme Support" or "{{companyName}} via Platform"
  fromEmail: string;      // requires verified domain
  replyToEmail: string;
  customDomain?: string;  // optional — for link tracking domains
}

// Sensible defaults for tenants who haven't configured branding
export const DEFAULT_BRANDING: TenantBrandingConfig = {
  tenantId: "default",
  companyName: "Our Platform",
  supportEmail: "support@platform.com",
  logoUrl: "https://platform.com/logo.png",
  logoWidth: 140,
  logoHeight: 40,
  primaryColor: "#18181B",
  secondaryColor: "#71717A",
  accentColor: "#F59E0B",
  headingFont: "'Helvetica Neue', Helvetica, Arial, sans-serif",
  bodyFont: "'Helvetica Neue', Helvetica, Arial, sans-serif",
  footerText: "Sent by Our Platform, Inc.",
  footerLinks: [
    { label: "Privacy Policy", url: "https://platform.com/privacy" },
    { label: "Terms of Service", url: "https://platform.com/terms" },
  ],
  fromName: "Our Platform",
  fromEmail: "noreply@platform.com",
  replyToEmail: "support@platform.com",
};
Store logoWidth and logoHeight alongside the URL. Email clients ignore CSS-only sizing on images, and without explicit dimensions your logo will render at full resolution in Outlook — potentially blowing out the layout.

In your database, this lives as a JSON column on the tenant/organization record, or as a dedicated tenant_branding table. Either works. The important thing is that you can fetch the full config with a single query.

lib/email/get-tenant-branding.ts
import { db } from "@/lib/db";
import { DEFAULT_BRANDING, type TenantBrandingConfig } from "./tenant-config";

export async function getTenantBranding(
  tenantId: string
): Promise<TenantBrandingConfig> {
  const tenant = await db.tenant.findUnique({
    where: { id: tenantId },
    select: { branding: true },
  });

  if (!tenant?.branding) return { ...DEFAULT_BRANDING, tenantId };

  // Merge with defaults so missing fields don't break templates
  return { ...DEFAULT_BRANDING, ...tenant.branding, tenantId };
}
Always merge with defaults. If a tenant has configured their logo but not their footer text, you don't want undefined rendering in the email. The spread merge ensures every field has a value.

Building brandable React Email components

The key insight: don't pass branding config to every individual element. Instead, build a TenantEmailLayout that wraps all your templates and handles the branded header, footer, and color system. Individual templates only worry about their unique content.

emails/components/tenant-email-layout.tsx
import {
  Html, Head, Preview, Body, Container,
  Section, Img, Text, Hr, Link,
} from "@react-email/components";
import { type TenantBrandingConfig } from "@/lib/email/tenant-config";

interface TenantEmailLayoutProps {
  branding: TenantBrandingConfig;
  preview: string;
  children: React.ReactNode;
}

export function TenantEmailLayout({
  branding,
  preview,
  children,
}: TenantEmailLayoutProps) {
  return (
    <Html>
      <Head />
      <Preview>{preview}</Preview>
      <Body style={{
        backgroundColor: "#f9fafb",
        fontFamily: branding.bodyFont,
        margin: 0,
        padding: 0,
      }}>
        <Container style={{
          maxWidth: "600px",
          margin: "0 auto",
          padding: "40px 20px",
        }}>
          {/* Branded header */}
          <Section style={{ marginBottom: "32px" }}>
            <Img
              src={branding.logoUrl}
              width={branding.logoWidth}
              height={branding.logoHeight}
              alt={branding.companyName}
              style={{ display: "block" }}
            />
          </Section>

          {/* Template content */}
          <Section style={{
            backgroundColor: "#ffffff",
            borderRadius: "8px",
            padding: "32px",
            border: "1px solid #e5e7eb",
          }}>
            {children}
          </Section>

          {/* Branded footer */}
          <Section style={{ marginTop: "32px", textAlign: "center" }}>
            <Text style={{
              color: "#9ca3af",
              fontSize: "12px",
              lineHeight: "20px",
              margin: "0 0 8px 0",
            }}>
              {branding.footerText}
            </Text>
            <Text style={{
              color: "#9ca3af",
              fontSize: "12px",
              lineHeight: "20px",
              margin: 0,
            }}>
              {branding.footerLinks.map((link, i) => (
                <span key={link.url}>
                  {i > 0 && " · "}
                  <Link href={link.url} style={{ color: "#9ca3af" }}>
                    {link.label}
                  </Link>
                </span>
              ))}
            </Text>
          </Section>
        </Container>
      </Body>
    </Html>
  );
}

A branded button primitive

Buttons are the most visible use of tenant color. Extract a BrandedButton so you never hardcode a hex value in a template.

emails/components/branded-button.tsx
import { Button } from "@react-email/components";
import { type TenantBrandingConfig } from "@/lib/email/tenant-config";

interface BrandedButtonProps {
  branding: TenantBrandingConfig;
  href: string;
  children: React.ReactNode;
  variant?: "primary" | "secondary";
}

export function BrandedButton({
  branding,
  href,
  children,
  variant = "primary",
}: BrandedButtonProps) {
  const bgColor = variant === "primary"
    ? branding.primaryColor
    : branding.secondaryColor;

  return (
    <Button
      href={href}
      style={{
        backgroundColor: bgColor,
        color: "#ffffff",
        padding: "12px 24px",
        borderRadius: "6px",
        fontSize: "14px",
        fontWeight: 600,
        fontFamily: branding.bodyFont,
        textDecoration: "none",
        display: "inline-block",
      }}
    >
      {children}
    </Button>
  );
}

The welcome email with tenant branding

Here is a complete template that looks entirely different for two tenants using the same code. The layout component handles the branded shell, the template handles the content, and the button picks up the tenant's primary color automatically.

emails/templates/welcome.tsx
import { Text, Heading } from "@react-email/components";
import { TenantEmailLayout } from "../components/tenant-email-layout";
import { BrandedButton } from "../components/branded-button";
import { type TenantBrandingConfig } from "@/lib/email/tenant-config";

interface WelcomeEmailProps {
  branding: TenantBrandingConfig;
  userName: string;
  dashboardUrl: string;
}

export function WelcomeEmail({
  branding,
  userName,
  dashboardUrl,
}: WelcomeEmailProps) {
  return (
    <TenantEmailLayout
      branding={branding}
      preview={`Welcome to ${branding.companyName}`}
    >
      <Heading style={{
        fontSize: "24px",
        fontWeight: 700,
        color: branding.primaryColor,
        fontFamily: branding.headingFont,
        margin: "0 0 16px 0",
      }}>
        Welcome to {branding.companyName}
      </Heading>

      <Text style={{
        fontSize: "15px",
        lineHeight: "24px",
        color: "#374151",
        margin: "0 0 16px 0",
      }}>
        Hi {userName}, your account is ready. You can access your dashboard,
        configure your settings, and start using {branding.companyName} right
        away.
      </Text>

      <BrandedButton branding={branding} href={dashboardUrl}>
        Go to your dashboard
      </BrandedButton>

      <Text style={{
        fontSize: "13px",
        lineHeight: "20px",
        color: "#9ca3af",
        marginTop: "24px",
      }}>
        Need help getting started? Reply to this email or contact us at{" "}
        {branding.supportEmail}.
      </Text>
    </TenantEmailLayout>
  );
}

With this structure, Tenant A (a fintech company with a deep blue brand) and Tenant B (a health tech startup using green) both get emails that match their brand identity — same codebase, same template, zero conditional branching.

The heading color, button color, logo, footer, and company name all change automatically. A new tenant just needs to fill in their branding config and every email they send through your platform is on-brand from day one.

Rendering with tenant context

The send function is where configuration meets execution. Fetch the tenant branding, pass it to the template, render to HTML, and send with the correct from address.

lib/email/send-tenant-email.ts
import { render } from "@react-email/render";
import { Resend } from "resend";
import { getTenantBranding } from "./get-tenant-branding";
import { WelcomeEmail } from "@/emails/templates/welcome";

const resend = new Resend(process.env.RESEND_API_KEY);

interface SendEmailOptions {
  tenantId: string;
  to: string;
  subject: string;
  template: React.ReactElement;
}

export async function sendTenantEmail({
  tenantId,
  to,
  subject,
  template,
}: SendEmailOptions) {
  const branding = await getTenantBranding(tenantId);

  const html = await render(template);

  const { error } = await resend.emails.send({
    from: `${branding.fromName} <${branding.fromEmail}>`,
    replyTo: branding.replyToEmail,
    to,
    subject,
    html,
  });

  if (error) {
    throw new Error(`Failed to send email: ${error.message}`);
  }
}

// Usage in an API route or server action:
export async function sendWelcome(tenantId: string, user: {
  email: string;
  name: string;
}) {
  const branding = await getTenantBranding(tenantId);

  await sendTenantEmail({
    tenantId,
    to: user.email,
    subject: `Welcome to ${branding.companyName}`,
    template: (
      <WelcomeEmail
        branding={branding}
        userName={user.name}
        dashboardUrl={`https://${branding.customDomain || "app.platform.com"}/dashboard`}
      />
    ),
  });
}
The fromEmail domain must be verified with your email provider. You cannot just set from: "noreply@customer.com" without the customer adding DNS records (SPF, DKIM) for your sending infrastructure. Build a domain verification flow in your tenant onboarding.

Preview system for tenant emails

During development and for customer support, you need a way to preview any email template with any tenant's branding. A simple Next.js API route handles this.

app/api/email-preview/route.tsx
import { NextRequest, NextResponse } from "next/server";
import { render } from "@react-email/render";
import { getTenantBranding } from "@/lib/email/get-tenant-branding";
import { DEFAULT_BRANDING } from "@/lib/email/tenant-config";
import { WelcomeEmail } from "@/emails/templates/welcome";
// import other templates...

const TEMPLATES: Record<string, (branding: any, props: any) => React.ReactElement> = {
  welcome: (branding, props) => (
    <WelcomeEmail
      branding={branding}
      userName={props.userName || "Jane Doe"}
      dashboardUrl={props.dashboardUrl || "https://app.example.com/dashboard"}
    />
  ),
  // Add more templates here
};

export async function GET(request: NextRequest) {
  // Only allow in development
  if (process.env.NODE_ENV !== "development") {
    return NextResponse.json({ error: "Not available" }, { status: 404 });
  }

  const { searchParams } = request.nextUrl;
  const templateName = searchParams.get("template") || "welcome";
  const tenantId = searchParams.get("tenantId");

  const branding = tenantId
    ? await getTenantBranding(tenantId)
    : DEFAULT_BRANDING;

  const templateFn = TEMPLATES[templateName];
  if (!templateFn) {
    return NextResponse.json(
      { error: `Unknown template: ${templateName}` },
      { status: 400 }
    );
  }

  // Parse any additional props from query params
  const props: Record<string, string> = {};
  searchParams.forEach((value, key) => {
    if (key !== "template" && key !== "tenantId") {
      props[key] = value;
    }
  });

  const html = await render(templateFn(branding, props));

  return new NextResponse(html, {
    headers: { "Content-Type": "text/html" },
  });
}

Visit /api/email-preview?template=welcome&tenantId=acme-corp to see exactly what Acme Corp's users will receive. Swap the tenantId parameter to preview other tenants. Omit it to see the default branding.

Build an internal tool page that lists all templates in a sidebar with a tenant picker dropdown. Your support team will thank you when a customer asks "why does my email look wrong?" and they can preview it in seconds.

Caching rendered templates

Tenant branding config changes rarely — maybe once a quarter when someone updates their logo. But you render the same template hundreds of times per day. A simple cache with TTL avoids redundant database lookups and rendering work.

lib/email/branding-cache.ts
import { type TenantBrandingConfig } from "./tenant-config";
import { getTenantBranding as fetchBranding } from "./get-tenant-branding";

interface CacheEntry {
  branding: TenantBrandingConfig;
  expiresAt: number;
}

const cache = new Map<string, CacheEntry>();
const TTL_MS = 5 * 60 * 1000; // 5 minutes

export async function getCachedBranding(
  tenantId: string
): Promise<TenantBrandingConfig> {
  const cached = cache.get(tenantId);

  if (cached && cached.expiresAt > Date.now()) {
    return cached.branding;
  }

  const branding = await fetchBranding(tenantId);

  cache.set(tenantId, {
    branding,
    expiresAt: Date.now() + TTL_MS,
  });

  return branding;
}

// Call this when a tenant updates their branding in the dashboard
export function invalidateBrandingCache(tenantId: string): void {
  cache.delete(tenantId);
}

// Prevent unbounded memory growth in long-running processes
export function pruneExpiredEntries(): void {
  const now = Date.now();
  for (const [key, entry] of cache) {
    if (entry.expiresAt <= now) {
      cache.delete(key);
    }
  }
}

Wire invalidateBrandingCache into your tenant settings API route so the cache clears immediately when a customer updates their branding. The 5-minute TTL is a safety net, not the primary invalidation mechanism.

For serverless deployments (Vercel, AWS Lambda), in-memory caches reset on cold starts. That's fine here — the cache is a performance optimization, not a correctness requirement. For persistent caching, use Redis or Upstash.

Architecture comparison

There are two common approaches to multi-tenant email branding. The first is tempting because it feels simple. The second is what you should actually build.

Dynamic template system
  • One template per email type, branding passed as props
  • New tenant = one config row in the database
  • Brand updates are self-serve via a settings page
  • Testing covers the template once, branding config is data
  • Scales to thousands of tenants without code changes
Hardcoded per tenant
  • Copy-paste templates for each tenant, change colors inline
  • New tenant = new template files, new deployment
  • Brand updates require a developer and a deploy
  • Every tenant's templates need separate test coverage
  • 50 tenants = 50x the templates to maintain

Testing multi-tenant templates

The beauty of prop-driven branding is that your tests are straightforward. Render the same template with different configs, assert the output contains the expected branding elements.

__tests__/emails/welcome.test.tsx
import { render } from "@react-email/render";
import { WelcomeEmail } from "@/emails/templates/welcome";
import { DEFAULT_BRANDING, type TenantBrandingConfig } from "@/lib/email/tenant-config";

const acmeBranding: TenantBrandingConfig = {
  ...DEFAULT_BRANDING,
  tenantId: "acme",
  companyName: "Acme Corp",
  logoUrl: "https://acme.com/logo.png",
  primaryColor: "#2563EB",
  supportEmail: "help@acme.com",
  fromEmail: "noreply@acme.com",
  fromName: "Acme Corp",
  footerText: "Sent by Acme Corp, 123 Main St, San Francisco, CA 94102",
};

describe("WelcomeEmail", () => {
  it("renders with default branding", async () => {
    const html = await render(
      <WelcomeEmail
        branding={DEFAULT_BRANDING}
        userName="Alex"
        dashboardUrl="https://app.platform.com/dashboard"
      />
    );

    expect(html).toContain("Our Platform");
    expect(html).toContain("Welcome to Our Platform");
    expect(html).toContain(DEFAULT_BRANDING.logoUrl);
    expect(html).toContain(DEFAULT_BRANDING.primaryColor);
  });

  it("renders with tenant branding", async () => {
    const html = await render(
      <WelcomeEmail
        branding={acmeBranding}
        userName="Alex"
        dashboardUrl="https://app.acme.com/dashboard"
      />
    );

    expect(html).toContain("Acme Corp");
    expect(html).toContain("Welcome to Acme Corp");
    expect(html).toContain("https://acme.com/logo.png");
    expect(html).toContain("#2563EB");
    expect(html).not.toContain("Our Platform");
  });

  it("includes support email from branding", async () => {
    const html = await render(
      <WelcomeEmail
        branding={acmeBranding}
        userName="Alex"
        dashboardUrl="https://app.acme.com/dashboard"
      />
    );

    expect(html).toContain("help@acme.com");
  });

  it("falls back gracefully for partial branding", async () => {
    const partialBranding = {
      ...DEFAULT_BRANDING,
      tenantId: "partial",
      companyName: "Partial Co",
      // Everything else uses defaults
    };

    const html = await render(
      <WelcomeEmail
        branding={partialBranding}
        userName="Alex"
        dashboardUrl="https://app.example.com/dashboard"
      />
    );

    expect(html).toContain("Partial Co");
    // Default logo should still render
    expect(html).toContain(DEFAULT_BRANDING.logoUrl);
  });
});

Snapshot testing per tenant

For visual regressions, generate snapshots for a representative set of tenant configs. Don't snapshot every tenant — pick 3-4 that cover the extremes: default branding, a tenant with a very long company name, one with a tall logo, and one with dark brand colors.

__tests__/emails/welcome.snapshot.test.tsx
const brandingVariants = [
  { name: "default", branding: DEFAULT_BRANDING },
  { name: "long-name", branding: {
    ...DEFAULT_BRANDING,
    companyName: "International Business Machines Corporation (Eastern Division)",
  }},
  { name: "dark-brand", branding: {
    ...DEFAULT_BRANDING,
    primaryColor: "#1a1a1a",
    secondaryColor: "#333333",
  }},
];

describe.each(brandingVariants)("WelcomeEmail ($name)", ({ branding }) => {
  it("matches snapshot", async () => {
    const html = await render(
      <WelcomeEmail
        branding={branding}
        userName="Test User"
        dashboardUrl="https://example.com/dashboard"
      />
    );

    expect(html).toMatchSnapshot();
  });
});

Production checklist

Key takeaway

Before shipping multi-tenant emails to production, verify each of these:

  • Schema validation: Validate tenant branding config on save — reject invalid hex colors, missing logo URLs, and empty company names
  • Domain verification: Build a flow for tenants to verify their sending domain (SPF, DKIM, DMARC records) before allowing custom fromEmail
  • Logo requirements: Enforce max dimensions (e.g., 280x80px) and file size limits. Serve logos from a CDN, never from tenant-controlled URLs directly
  • Color contrast: Check that tenant primary colors have sufficient contrast against white backgrounds for button text readability (WCAG AA minimum)
  • Fallback behavior: Every template must render correctly with DEFAULT_BRANDING — never crash on missing config
  • Cache invalidation: Wire branding cache clear to the tenant settings save endpoint. Stale logos are a top support ticket driver
  • Preview before launch: Give tenants a preview tool in their dashboard so they can see their branded emails before going live
  • Audit trail: Log branding config changes with timestamps and the user who made them. Essential for debugging "my emails looked different yesterday"

Next steps

Multi-tenant email branding is one of those features that feels like it should be simple but touches authentication (domain verification), infrastructure (email provider config), design systems (accessible color enforcement), and devops (cache invalidation). Getting the architecture right at the schema and layout level makes everything downstream straightforward.

Start with the TenantBrandingConfig interface and the TenantEmailLayout component. Once those are solid, every new template you add automatically supports full tenant branding with zero extra work.

For related patterns, see Design Tokens and Theming in React Email for a deeper look at design token architecture, and Email Template Caching Strategies for advanced caching beyond the simple Map approach shown here.

R

React Emails Pro

Team

Building production-ready email templates with React Email. Writing about transactional email best practices, deliverability, and developer tooling.

Production-ready templates

Pick from 9 template packs built with React Email. One-time purchase, lifetime updates, tested across every major email client.

Browse all templates