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.
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",
};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.
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 };
}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.
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.
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.
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.
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.
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`}
/>
),
});
}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.
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.
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.
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.
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.
- 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
- 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.
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.
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
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.