White-Label веб-приложение
White-label позволяет клиентам продавать ваш продукт под своим брендом: свой домен, логотип, цвета, email-шаблоны. Пользователи клиента не знают о вашей платформе.
Архитектура White-Label
Два подхода к изоляции:
По субдомену — acme.yourplatform.com или app.acmecorp.com (custom domain)
По записям DNS — клиент добавляет CNAME: app.acmecorp.com → yourplatform.com
// middleware.ts: определяем тенанта по домену
export async function middleware(request: NextRequest) {
const hostname = request.headers.get('host')!;
// Убираем порт для localhost
const domain = hostname.replace(':3000', '');
// Загружаем tenant по домену
const tenant = await getTenantByDomain(domain);
if (!tenant) {
return NextResponse.rewrite(new URL('/404', request.url));
}
// Пробрасываем tenant в заголовках
const response = NextResponse.next();
response.headers.set('x-tenant-id', tenant.id);
response.headers.set('x-tenant-slug', tenant.slug);
return response;
}
Custom Domain: верификация
// При добавлении custom domain клиентом
// 1. Клиент добавляет CNAME в DNS
// 2. Мы верифицируем через DNS lookup
// 3. Выпускаем SSL через Let's Encrypt или Cloudflare
export async function verifyCustomDomain(tenantId: string, domain: string) {
const dns = await import('dns/promises');
// Проверяем CNAME запись
let cnameTarget: string;
try {
const records = await dns.resolveCname(domain);
cnameTarget = records[0];
} catch {
return { verified: false, error: 'CNAME not found' };
}
const expectedCname = `${process.env.PLATFORM_DOMAIN}`;
if (cnameTarget !== expectedCname) {
return {
verified: false,
error: `CNAME must point to ${expectedCname}, found: ${cnameTarget}`
};
}
// Сохраняем домен
await db.tenant.update({
where: { id: tenantId },
data: {
customDomain: domain,
customDomainVerifiedAt: new Date(),
}
});
// Cloudflare: добавляем custom hostname
await addCloudflareCustomHostname(domain, tenantId);
return { verified: true };
}
// Cloudflare Custom Hostnames API
async function addCloudflareCustomHostname(domain: string, tenantId: string) {
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${process.env.CF_ZONE_ID}/custom_hostnames`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CF_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
hostname: domain,
ssl: { method: 'http', type: 'dv' },
custom_metadata: { tenant_id: tenantId },
}),
}
);
return response.json();
}
Брендинг: конфигурация тенанта
model TenantBranding {
id String @id @default(cuid())
tenantId String @unique
logoUrl String?
faviconUrl String?
appName String
primaryColor String @default("#6366f1")
secondaryColor String @default("#f1f5f9")
fontFamily String @default("Inter")
supportEmail String?
supportUrl String?
privacyUrl String?
termsUrl String?
footerText String?
hideWatermark Boolean @default(false) // белая метка
customCss String? @db.Text // произвольный CSS
emailFromName String?
emailFromAddress String?
emailLogoUrl String?
}
// CSS переменные из настроек тенанта
export function TenantStylesheet({ branding }: { branding: TenantBranding }) {
const css = `
:root {
--color-primary: ${branding.primaryColor};
--color-secondary: ${branding.secondaryColor};
--font-family: '${branding.fontFamily}', sans-serif;
}
${branding.customCss ?? ''}
`;
return <style dangerouslySetInnerHTML={{ __html: css }} />;
}
// app/layout.tsx: динамическая метаинформация
import { headers } from 'next/headers';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
const tenantId = headers().get('x-tenant-id');
const branding = await db.tenantBranding.findUnique({
where: { tenantId: tenantId! }
});
return {
title: branding?.appName ?? 'App',
icons: { icon: branding?.faviconUrl ?? '/favicon.ico' },
};
}
export default async function RootLayout({ children }) {
const tenantId = headers().get('x-tenant-id');
const branding = await db.tenantBranding.findUnique({
where: { tenantId: tenantId! }
});
return (
<html>
<head>
{branding?.fontFamily !== 'Inter' && (
<link
href={`https://fonts.googleapis.com/css2?family=${branding.fontFamily}:wght@400;500;600&display=swap`}
rel="stylesheet"
/>
)}
</head>
<body>
<TenantStylesheet branding={branding!} />
{children}
{!branding?.hideWatermark && <PlatformWatermark />}
</body>
</html>
);
}
Email-шаблоны с брендингом
// Все исходящие письма используют брендинг тенанта
export async function sendBrandedEmail(
tenantId: string,
to: string,
template: string,
variables: Record<string, string>
) {
const branding = await db.tenantBranding.findUnique({
where: { tenantId }
});
await resend.emails.send({
from: branding?.emailFromAddress
? `${branding.emailFromName} <${branding.emailFromAddress}>`
: `[email protected]`,
to,
subject: variables.subject,
react: EmailTemplate({
...variables,
logoUrl: branding?.emailLogoUrl,
brandColor: branding?.primaryColor ?? '#6366f1',
appName: branding?.appName ?? 'App',
footerText: branding?.footerText,
}),
});
}
Разработка white-label системы с custom domains, брендингом и email-шаблонами — 5–10 рабочих дней.







