Back to Blog

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.

Viprasol Tech Team
January 16, 2027
14 min read

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

ComponentTimelineCost (USD)
Database schema + tenant resolution middleware1–2 days$800–$1,600
CSS custom property theming1 day$600–$1,000
Custom domain provisioning + CNAME verification2–3 days$1,600–$2,500
White-label email with SES domain verification2–3 days$1,600–$2,500
Branding settings UI1–2 days$800–$1,600
Full white-label system2–3 weeks$10,000–$18,000

AWS SES white-label email cost: $0.10 per 1,000 emails. Domain verification is free.


See Also


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.

Share this article:

About the Author

V

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.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Building a SaaS Product?

We've helped launch 50+ SaaS platforms. Let's build yours β€” fast.

Free consultation β€’ No commitment β€’ Response within 24 hours

Viprasol Β· AI Agent Systems

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.