SaaS White-Labeling in 2026: Custom Domains, Branding Theming, and Email From Domains
Build a production SaaS white-label system: custom domain provisioning with Vercel/Cloudflare, per-tenant CSS theming, white-label email from domains with SES, and brand isolation.
SaaS White-Labeling in 2026: Custom Domains, Branding Theming, and Email From Domains
White-labeling lets your enterprise customers put their brand on your product. Their logo, their colors, their domainβapp.theircorp.com instead of app.yoursaas.com. For B2B SaaS, white-labeling is often the difference between winning and losing enterprise deals.
Getting it right requires solving three distinct problems: routing custom domains to the right tenant, applying per-tenant branding without layout shift, and sending emails from the customer's domain without landing in spam. This post covers all three with production-ready implementations.
Architecture Overview
User visits: app.acmecorp.com
β
Cloudflare / Vercel Edge (CNAME resolution)
β
Your Next.js app receives request
β
Middleware reads Host header β looks up tenant
β
Request continues with tenant context injected
β
Page renders with Acme Corp branding
The key insight: your app is the same for all tenants. The host header is the only difference. Middleware resolves it to a tenant ID, which flows through the request.
Database Schema
-- migrations/20260101_white_label.sql
ALTER TABLE teams ADD COLUMN IF NOT EXISTS
custom_domain TEXT UNIQUE; -- "app.acmecorp.com"
ALTER TABLE teams ADD COLUMN IF NOT EXISTS
subdomain TEXT UNIQUE; -- "acmecorp" (for acmecorp.yoursaas.com)
CREATE TABLE team_branding (
team_id UUID PRIMARY KEY REFERENCES teams(id) ON DELETE CASCADE,
-- Identity
brand_name TEXT NOT NULL, -- "Acme Corp" (replaces your product name)
logo_url TEXT, -- Full URL to their logo
favicon_url TEXT,
-- Colors (CSS custom properties)
primary_color TEXT NOT NULL DEFAULT '#2563EB', -- --color-primary
secondary_color TEXT NOT NULL DEFAULT '#1E40AF', -- --color-secondary
accent_color TEXT NOT NULL DEFAULT '#DBEAFE', -- --color-accent
text_color TEXT NOT NULL DEFAULT '#111827',
bg_color TEXT NOT NULL DEFAULT '#FFFFFF',
-- Typography
font_family TEXT DEFAULT 'Inter, system-ui, sans-serif',
-- Email settings
from_name TEXT, -- "Acme Corp Team"
from_email TEXT, -- "notifications@acmecorp.com"
reply_to_email TEXT,
-- Custom links
privacy_url TEXT,
terms_url TEXT,
support_url TEXT,
help_center_url TEXT,
-- Domain verification
custom_domain_verified BOOLEAN NOT NULL DEFAULT FALSE,
custom_domain_verified_at TIMESTAMPTZ,
email_domain_verified BOOLEAN NOT NULL DEFAULT FALSE,
email_domain_verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Fast lookup by custom domain (used on every request)
CREATE INDEX idx_teams_custom_domain ON teams(custom_domain)
WHERE custom_domain IS NOT NULL;
CREATE INDEX idx_teams_subdomain ON teams(subdomain)
WHERE subdomain IS NOT NULL;
π SaaS MVP in 8 Weeks β Seriously
We have launched 50+ SaaS platforms. Multi-tenant architecture, Stripe billing, auth, role-based access, and cloud deployment β all handled by one senior team.
- Week 1β2: Architecture design + wireframes
- Week 3β6: Core features built + tested
- Week 7β8: Launch-ready on AWS/Vercel with CI/CD
- Post-launch: Maintenance plans from month 3
Next.js Middleware: Tenant Resolution
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
// Your app's root domain
const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN!; // "yoursaas.com"
const ROOT_HOSTS = new Set([
ROOT_DOMAIN,
`www.${ROOT_DOMAIN}`,
"localhost:3000",
]);
export async function middleware(req: NextRequest) {
const host = req.headers.get("host") ?? "";
const hostname = host.split(":")[0]; // Strip port
// Skip static files and API routes that don't need tenant context
const { pathname } = req.nextUrl;
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/api/webhooks") || // Public webhook endpoints
pathname.startsWith("/favicon.ico")
) {
return NextResponse.next();
}
// Root domain β no tenant context needed
if (ROOT_HOSTS.has(hostname)) {
return NextResponse.next();
}
// Resolve tenant from hostname
const tenant = await resolveTenant(hostname);
if (!tenant) {
// Unknown domain β could be misconfigured or a bot scan
return new NextResponse("Not found", { status: 404 });
}
// Inject tenant into request headers (readable by Server Components)
const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-tenant-id", tenant.id);
requestHeaders.set("x-tenant-domain", hostname);
requestHeaders.set("x-tenant-brand-name", tenant.brandName ?? "");
return NextResponse.next({ request: { headers: requestHeaders } });
}
async function resolveTenant(
hostname: string
): Promise<{ id: string; brandName: string | null } | null> {
// Check if it's a subdomain of our root domain
const subdomainMatch = hostname.match(
new RegExp(`^(.+)\\.${ROOT_DOMAIN.replace(".", "\\.")}$`)
);
let cacheKey: string;
let queryParam: string;
let queryValue: string;
if (subdomainMatch) {
const subdomain = subdomainMatch[1];
if (subdomain === "www" || subdomain === "app") return null;
cacheKey = `subdomain:${subdomain}`;
queryParam = "subdomain";
queryValue = subdomain;
} else {
// Full custom domain
cacheKey = `domain:${hostname}`;
queryParam = "custom_domain";
queryValue = hostname;
}
// Fetch from edge-compatible DB or cache
// In production: use a KV store (Cloudflare KV, Upstash Redis) for edge lookup
const response = await fetch(
`${process.env.INTERNAL_API_URL}/api/internal/tenant-lookup?${queryParam}=${encodeURIComponent(queryValue)}`,
{
headers: { "x-internal-secret": process.env.INTERNAL_API_SECRET! },
next: { revalidate: 300 }, // Cache for 5 minutes at the edge
}
);
if (!response.ok) return null;
return response.json();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
Reading Tenant Context in Server Components
// lib/tenant/context.ts
import { headers } from "next/headers";
import { db } from "@/lib/db";
import { cache } from "react";
export interface TenantBranding {
teamId: string;
brandName: string;
logoUrl: string | null;
faviconUrl: string | null;
primaryColor: string;
secondaryColor: string;
accentColor: string;
textColor: string;
bgColor: string;
fontFamily: string;
fromName: string | null;
fromEmail: string | null;
privacyUrl: string | null;
termsUrl: string | null;
supportUrl: string | null;
}
// cache() memoizes per request β only hits DB once per page render
export const getTenantBranding = cache(async (): Promise<TenantBranding | null> => {
const headersList = await headers();
const tenantId = headersList.get("x-tenant-id");
if (!tenantId) return null;
const branding = await db.teamBranding.findUnique({
where: { teamId: tenantId },
});
if (!branding) return null;
return {
teamId: tenantId,
brandName: branding.brandName,
logoUrl: branding.logoUrl,
faviconUrl: branding.faviconUrl,
primaryColor: branding.primaryColor,
secondaryColor: branding.secondaryColor,
accentColor: branding.accentColor,
textColor: branding.textColor,
bgColor: branding.bgColor,
fontFamily: branding.fontFamily ?? "Inter, system-ui, sans-serif",
fromName: branding.fromName,
fromEmail: branding.fromEmail,
privacyUrl: branding.privacyUrl,
termsUrl: branding.termsUrl,
supportUrl: branding.supportUrl,
};
});
π‘ The Difference Between a SaaS Demo and a SaaS Business
Anyone can build a demo. We build SaaS products that handle real load, real users, and real payments β with architecture that does not need to be rewritten at 1,000 users.
- Multi-tenant PostgreSQL with row-level security
- Stripe subscriptions, usage billing, annual plans
- SOC2-ready infrastructure from day one
- We own zero equity β you own everything
CSS Theming with Custom Properties
// app/layout.tsx
import { getTenantBranding } from "@/lib/tenant/context";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const branding = await getTenantBranding();
// Build CSS custom properties from tenant branding
const cssVars = branding
? {
"--color-primary": branding.primaryColor,
"--color-secondary": branding.secondaryColor,
"--color-accent": branding.accentColor,
"--color-text": branding.textColor,
"--color-bg": branding.bgColor,
"--font-family": branding.fontFamily,
}
: {}; // Default theme from CSS
return (
<html lang="en">
<head>
{branding?.faviconUrl && (
<link rel="icon" href={branding.faviconUrl} />
)}
</head>
<body style={cssVars as React.CSSProperties}>
{children}
</body>
</html>
);
}
// app/layout.tsx β generateMetadata for per-tenant SEO
export async function generateMetadata() {
const branding = await getTenantBranding();
const appName = branding?.brandName ?? "YourSaaS";
const logoUrl = branding?.logoUrl ?? "/images/default-logo.png";
return {
title: { template: `%s β ${appName}`, default: appName },
description: `Powered by ${appName}`,
icons: { icon: branding?.faviconUrl ?? "/favicon.ico" },
openGraph: {
siteName: appName,
images: [{ url: logoUrl }],
},
};
}
Tailwind CSS integration:
/* app/globals.css */
:root {
--color-primary: #2563EB;
--color-secondary: #1E40AF;
--color-accent: #DBEAFE;
--color-text: #111827;
--color-bg: #FFFFFF;
--font-family: Inter, system-ui, sans-serif;
}
body {
font-family: var(--font-family);
color: var(--color-text);
background-color: var(--color-bg);
}
// tailwind.config.js β use CSS vars in Tailwind classes
module.exports = {
theme: {
extend: {
colors: {
primary: "var(--color-primary)",
secondary: "var(--color-secondary)",
accent: "var(--color-accent)",
},
fontFamily: {
sans: ["var(--font-family)"],
},
},
},
};
Now className="bg-primary text-white" automatically uses the tenant's primary color.
Custom Domain Provisioning
When a customer adds their custom domain, they need to set a CNAME:
app.acmecorp.com CNAME proxy.yoursaas.com
Your provisioning flow:
// app/api/settings/custom-domain/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { verifyDomainOwnership } from "@/lib/domains/verify";
export async function POST(req: NextRequest) {
const user = await getCurrentUser();
if (!user?.isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { domain } = await req.json();
// Basic domain validation
const domainRegex = /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i;
if (!domainRegex.test(domain)) {
return NextResponse.json({ error: "Invalid domain format" }, { status: 400 });
}
// Check not already taken
const existing = await db.team.findFirst({
where: { customDomain: domain, id: { not: user.teamId } },
});
if (existing) {
return NextResponse.json({ error: "Domain already in use" }, { status: 409 });
}
// Save domain (unverified)
await db.team.update({
where: { id: user.teamId },
data: { customDomain: domain },
});
// Return DNS instructions
return NextResponse.json({
domain,
instructions: {
type: "CNAME",
name: domain,
value: `proxy.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`,
ttl: 3600,
},
verificationEndpoint: `/api/settings/custom-domain/verify`,
});
}
// Verify CNAME is pointing correctly
export async function PUT(req: NextRequest) {
const user = await getCurrentUser();
if (!user?.isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const team = await db.team.findUnique({ where: { id: user.teamId } });
if (!team?.customDomain) {
return NextResponse.json({ error: "No custom domain configured" }, { status: 400 });
}
const isVerified = await verifyDomainOwnership(team.customDomain);
if (!isVerified) {
return NextResponse.json({
verified: false,
message: "CNAME not yet propagated. DNS changes can take up to 48 hours.",
});
}
await db.teamBranding.update({
where: { teamId: user.teamId },
data: {
customDomainVerified: true,
customDomainVerifiedAt: new Date(),
},
});
return NextResponse.json({ verified: true });
}
// lib/domains/verify.ts
import dns from "dns/promises";
export async function verifyDomainOwnership(domain: string): Promise<boolean> {
try {
const addresses = await dns.resolveCname(domain);
const expectedTarget = `proxy.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`;
return addresses.some((addr) => addr === expectedTarget || addr === `${expectedTarget}.`);
} catch {
return false; // DNS lookup failed β domain not yet pointing correctly
}
}
White-Label Email with AWS SES
Send emails from the customer's domain (notifications@acmecorp.com) instead of yours:
// lib/email/white-label-sender.ts
import {
SESv2Client,
SendEmailCommand,
CreateEmailIdentityCommand,
GetEmailIdentityCommand,
} from "@aws-sdk/client-sesv2";
const ses = new SESv2Client({ region: process.env.AWS_REGION });
/**
* Onboard a new email domain for white-label sending.
* Returns the DNS records the customer needs to add.
*/
export async function provisionEmailDomain(domain: string) {
const identity = await ses.send(
new CreateEmailIdentityCommand({
EmailIdentity: domain,
DkimSigningAttributes: {
NextSigningKeyLength: "RSA_2048_BIT",
},
Tags: [{ Key: "purpose", Value: "white-label" }],
})
);
// Return DKIM DNS records for the customer to add
return {
domain,
dnsRecords: identity.DkimAttributes?.Tokens?.map((token) => ({
type: "CNAME",
name: `${token}._domainkey.${domain}`,
value: `${token}.dkim.amazonses.com`,
})) ?? [],
};
}
/**
* Check if the domain is verified and ready for sending.
*/
export async function checkEmailDomainStatus(domain: string) {
const identity = await ses.send(
new GetEmailIdentityCommand({ EmailIdentity: domain })
);
return {
verified: identity.VerifiedForSendingStatus ?? false,
dkimStatus: identity.DkimAttributes?.Status, // PENDING | SUCCESS | FAILED | TEMPORARY_FAILURE
};
}
/**
* Send an email from the tenant's domain.
*/
export async function sendWhiteLabelEmail({
branding,
to,
subject,
html,
text,
}: {
branding: { fromName: string; fromEmail: string; emailDomainVerified: boolean };
to: string;
subject: string;
html: string;
text: string;
}) {
// Fall back to your default sender if tenant domain isn't verified
const fromAddress = branding.emailDomainVerified && branding.fromEmail
? `${branding.fromName} <${branding.fromEmail}>`
: `${process.env.DEFAULT_FROM_NAME} <${process.env.DEFAULT_FROM_EMAIL}>`;
await ses.send(
new SendEmailCommand({
FromEmailAddress: fromAddress,
Destination: { ToAddresses: [to] },
Content: {
Simple: {
Subject: { Data: subject, Charset: "UTF-8" },
Body: {
Html: { Data: html, Charset: "UTF-8" },
Text: { Data: text, Charset: "UTF-8" },
},
},
},
})
);
}
Branding Settings UI
// components/BrandingSettings/BrandingSettings.tsx
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const BrandingSchema = z.object({
brandName: z.string().min(1).max(100),
primaryColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/, "Must be a valid hex color"),
secondaryColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/),
accentColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/),
fromName: z.string().max(100).optional(),
privacyUrl: z.string().url().optional().or(z.literal("")),
termsUrl: z.string().url().optional().or(z.literal("")),
supportUrl: z.string().url().optional().or(z.literal("")),
});
type BrandingForm = z.infer<typeof BrandingSchema>;
export function BrandingSettings({ initial }: { initial: BrandingForm }) {
const [saved, setSaved] = useState(false);
const { register, handleSubmit, watch, formState: { errors, isSubmitting } } =
useForm<BrandingForm>({
resolver: zodResolver(BrandingSchema),
defaultValues: initial,
});
const primaryColor = watch("primaryColor");
const onSubmit = async (data: BrandingForm) => {
await fetch("/api/settings/branding", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
setSaved(true);
setTimeout(() => setSaved(false), 3000);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 max-w-lg">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Brand Name
</label>
<input
{...register("brandName")}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Your Company Name"
/>
{errors.brandName && (
<p className="text-xs text-red-500 mt-1">{errors.brandName.message}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Primary Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
{...register("primaryColor")}
className="h-10 w-16 rounded border border-gray-300 cursor-pointer p-0.5"
/>
<input
{...register("primaryColor")}
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono"
placeholder="#2563EB"
/>
</div>
{/* Live preview */}
<div
className="mt-2 h-10 rounded-lg flex items-center justify-center text-white text-sm font-medium"
style={{ backgroundColor: primaryColor }}
>
Preview Button
</div>
</div>
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? "Saving..." : saved ? "Saved β" : "Save Branding"}
</button>
</form>
);
}
Cost and Timeline Estimates
| Component | Timeline | Cost (USD) |
|---|---|---|
| Database schema + tenant resolution middleware | 1β2 days | $800β$1,600 |
| CSS custom property theming | 1 day | $600β$1,000 |
| Custom domain provisioning + CNAME verification | 2β3 days | $1,600β$2,500 |
| White-label email with SES domain verification | 2β3 days | $1,600β$2,500 |
| Branding settings UI | 1β2 days | $800β$1,600 |
| Full white-label system | 2β3 weeks | $10,000β$18,000 |
AWS SES white-label email cost: $0.10 per 1,000 emails. Domain verification is free.
See Also
- SaaS Multi-Region Deployment β Running white-label tenants across regions
- Next.js Internationalization β Per-tenant locale support
- SaaS Team Invitations β Sending onboarding emails from custom domains
- SaaS GDPR Data Export β Per-tenant data isolation and export
Working With Viprasol
We build white-label SaaS infrastructure for B2B products β from simple logo-and-color theming through full custom domain and email provisioning. Our team has shipped white-label systems for products serving hundreds of enterprise tenants.
What we deliver:
- Custom domain provisioning with automated CNAME verification
- Per-tenant CSS theming with zero layout shift
- White-label email sending via AWS SES with DKIM verification
- Branding configuration UI for tenant admins
- Edge-optimized tenant resolution for sub-10ms middleware latency
Explore our SaaS development services or contact us to add white-labeling to your product.
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Building a SaaS Product?
We've helped launch 50+ SaaS platforms. Let's build yours β fast.
Free consultation β’ No commitment β’ Response within 24 hours
Add AI automation to your SaaS product?
Viprasol builds custom AI agent crews that plug into any SaaS workflow β automating repetitive tasks, qualifying leads, and responding across every channel your customers use.