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.