Skip to main content

Best Boilerplates for White-Label SaaS Products 2026

·StarterPick Team
white-labelmulti-tenantcustom-domainsaas-boilerplatevercel2026

TL;DR

White-label SaaS = multi-tenancy + per-tenant custom domains + per-tenant branding. The technical complexity is subdomain routing and DNS management. In 2026, Vercel Domains API + Next.js middleware is the standard for custom domain handling. Per-tenant branding (logo, colors, fonts) is solved with CSS variables in the database. Supastarter and Bedrock are the only two boilerplates with serious white-label support out-of-the-box.

Key Takeaways

  • Custom domains: Vercel Domains API (programmatic DNS management) or Cloudflare via API
  • Subdomain routing: Next.js middleware detects *.yourdomain.com → loads tenant config
  • Per-tenant branding: Store theme config in DB, inject as CSS variables at request time
  • DNS flow: Customer adds CNAME → Your API calls Vercel/Cloudflare API → Domain resolves
  • SSL: Automatic with Vercel custom domains; handled for you
  • Supastarter: Best pre-built white-label support with domain management UI

The White-Label Architecture

Customer sets up:
  1. Add CNAME to their DNS: app.theirclient.com → your-vercel-app.vercel.app
  2. Submit domain in your dashboard

Your platform:
  1. Call Vercel Domains API to register domain
  2. Verify CNAME propagated
  3. Next.js middleware detects custom domain → loads tenant config
  4. Serve app with tenant's branding

Custom Domain Routing (Next.js + Vercel)

// middleware.ts — detect custom domains and subdomains:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export async function middleware(request: NextRequest) {
  const hostname = request.headers.get('host') || '';
  const url = request.nextUrl;
  
  // Check if this is a custom domain or subdomain:
  const rootDomain = process.env.ROOT_DOMAIN!;  // yourdomain.com
  
  let tenantSlug: string | null = null;
  
  // 1. Custom domain (not yourdomain.com):
  if (!hostname.endsWith(`.${rootDomain}`) && hostname !== rootDomain) {
    // Look up tenant by custom domain:
    tenantSlug = await getTenantByDomain(hostname);
  }
  // 2. Subdomain (client.yourdomain.com):
  else if (hostname.endsWith(`.${rootDomain}`)) {
    tenantSlug = hostname.replace(`.${rootDomain}`, '');
  }
  
  if (tenantSlug) {
    // Rewrite URL to tenant-specific pages:
    return NextResponse.rewrite(new URL(`/tenant/${tenantSlug}${url.pathname}`, request.url));
  }
  
  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
// lib/domains.ts — Vercel Domains API integration:
const VERCEL_TOKEN = process.env.VERCEL_TOKEN!;
const VERCEL_PROJECT_ID = process.env.VERCEL_PROJECT_ID!;
const VERCEL_TEAM_ID = process.env.VERCEL_TEAM_ID;

export async function addCustomDomain(domain: string) {
  const response = await fetch(
    `https://api.vercel.com/v9/projects/${VERCEL_PROJECT_ID}/domains?${VERCEL_TEAM_ID ? `teamId=${VERCEL_TEAM_ID}` : ''}`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${VERCEL_TOKEN}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ name: domain }),
    }
  );
  
  const data = await response.json();
  
  if (!response.ok) {
    if (data.error?.code === 'domain_already_in_use') {
      throw new Error('Domain already in use by another project');
    }
    throw new Error(data.error?.message ?? 'Failed to add domain');
  }
  
  return data;
}

export async function removeDomain(domain: string) {
  await fetch(
    `https://api.vercel.com/v9/projects/${VERCEL_PROJECT_ID}/domains/${domain}`,
    {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${VERCEL_TOKEN}` },
    }
  );
}

export async function getDomainConfig(domain: string) {
  const response = await fetch(
    `https://api.vercel.com/v6/domains/${domain}/config`,
    { headers: { Authorization: `Bearer ${VERCEL_TOKEN}` } }
  );
  return response.json();
}

Per-Tenant Branding with CSS Variables

// Storing tenant theme in DB:
model TenantTheme {
  id              String @id @default(cuid())
  tenantId        String @unique
  primaryColor    String @default("#3b82f6")
  secondaryColor  String @default("#64748b")
  backgroundColor String @default("#ffffff")
  fontFamily      String @default("Inter")
  logoUrl         String?
  faviconUrl      String?
  borderRadius    String @default("0.5rem")
}
// app/tenant/[slug]/layout.tsx — inject theme:
import { getTenantTheme } from '@/lib/tenant';

export default async function TenantLayout({ params, children }) {
  const theme = await getTenantTheme(params.slug);
  
  const cssVariables = theme ? `
    :root {
      --color-primary: ${theme.primaryColor};
      --color-secondary: ${theme.secondaryColor};
      --color-background: ${theme.backgroundColor};
      --font-family: '${theme.fontFamily}', system-ui, sans-serif;
      --border-radius: ${theme.borderRadius};
    }
  ` : '';
  
  return (
    <html>
      <head>
        <style dangerouslySetInnerHTML={{ __html: cssVariables }} />
        {theme?.faviconUrl && <link rel="icon" href={theme.faviconUrl} />}
      </head>
      <body style={{ fontFamily: 'var(--font-family)' }}>
        {children}
      </body>
    </html>
  );
}
// Tenant branding admin UI:
'use client';
import { useState } from 'react';
import { HexColorPicker } from 'react-colorful';

export function BrandingSettings({ initialTheme }: { initialTheme: TenantTheme }) {
  const [theme, setTheme] = useState(initialTheme);
  
  const saveTheme = async () => {
    await fetch('/api/tenant/theme', {
      method: 'PUT',
      body: JSON.stringify(theme),
    });
  };
  
  return (
    <div className="space-y-6">
      <div>
        <label>Primary Color</label>
        <HexColorPicker 
          color={theme.primaryColor} 
          onChange={(color) => setTheme({ ...theme, primaryColor: color })} 
        />
        <input value={theme.primaryColor} onChange={(e) => setTheme({ ...theme, primaryColor: e.target.value })} />
      </div>
      
      <div>
        <label>Logo</label>
        <input type="file" accept="image/*" onChange={handleLogoUpload} />
        {theme.logoUrl && <img src={theme.logoUrl} alt="Logo preview" className="h-12" />}
      </div>
      
      <div>
        <label>Custom Domain</label>
        <input value={theme.customDomain} onChange={(e) => setTheme({...theme, customDomain: e.target.value})} 
          placeholder="app.yourclient.com" />
        <button onClick={saveDomain}>Add Domain</button>
      </div>
      
      <button onClick={saveTheme} className="btn btn-primary">Save Branding</button>
    </div>
  );
}

Domain Setup Flow for Customers

// app/api/tenant/domains/route.ts:
export async function POST(req: Request) {
  const { domain } = await req.json();
  const session = await auth();
  
  // Validate domain format:
  if (!/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]\.[a-z]{2,}$/.test(domain)) {
    return Response.json({ error: 'Invalid domain format' }, { status: 400 });
  }
  
  // Check if domain is already taken:
  const existing = await db.tenant.findFirst({ where: { customDomain: domain } });
  if (existing && existing.id !== session!.tenantId) {
    return Response.json({ error: 'Domain already in use' }, { status: 409 });
  }
  
  // Register with Vercel:
  await addCustomDomain(domain);
  
  // Save to DB:
  await db.tenant.update({
    where: { id: session!.tenantId },
    data: { customDomain: domain, domainVerified: false },
  });
  
  // Return DNS instructions:
  return Response.json({
    cname: {
      type: 'CNAME',
      name: '@' or the subdomain,
      value: 'cname.vercel-dns.com',
    },
    instructions: 'Add this CNAME record to your DNS provider. Propagation takes 5-30 minutes.',
  });
}

Decision Guide

Choose Supastarter for white-label if:
  → Need pre-built domain management UI
  → Don't want to implement Vercel API integration
  → React + Next.js stack
  → Worth the $299/license for faster setup

Build custom white-label on T3/ShipFast if:
  → Need specific customization
  → Have engineering bandwidth
  → Vercel Domains API integration is straightforward (~1 day)
  → Want full TypeScript control

White-label readiness checklist:
  ✅ Vercel Domains API integration
  ✅ Next.js middleware for domain routing
  ✅ Per-tenant theme (CSS variables in DB)
  ✅ Per-tenant logo/favicon
  ✅ Subdomain routing (*.yourdomain.com)
  ✅ DNS instructions for customers
  ✅ Domain verification UI

Find white-label SaaS boilerplates at StarterPick.

Comments