SaaS Multi-tenancy via Subdomains
Subdomain architecture: each client gets {slug}.app.com. User sees their brand in address bar, data isolated at request level.
Wildcard DNS and SSL
# DNS: wildcard record
*.app.com → 1.2.3.4 (your server)
# Let's Encrypt: wildcard SSL
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d "app.com" -d "*.app.com"
# nginx.conf: subdomain handling
server {
listen 443 ssl;
server_name ~^(?<subdomain>[^.]+)\.app\.com$;
ssl_certificate /etc/letsencrypt/live/app.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.com/privkey.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header X-Tenant-Slug $subdomain;
proxy_set_header Host $host;
}
}
Next.js: Tenant Detection
// middleware.ts
export async function middleware(request: NextRequest) {
const hostname = request.headers.get('host')!;
const rootDomain = process.env.ROOT_DOMAIN!; // app.com
const slug = hostname
.replace(`.${rootDomain}`, '')
.replace(':3000', '');
if (slug === rootDomain || slug === 'www') {
return NextResponse.next();
}
const tenant = await fetchTenant(slug);
if (!tenant) {
return NextResponse.rewrite(new URL('/tenant-not-found', request.url));
}
const response = NextResponse.next();
response.headers.set('x-tenant-id', tenant.id);
response.headers.set('x-tenant-slug', slug);
return response;
}
export const config = {
matcher: ['/((?!api/|_next/|_static/|[\\w-]+\\.\\w+).*)'],
};
// lib/tenant.ts: cached tenant access
import { cache } from 'react';
export const getCurrentTenant = cache(async () => {
const tenantId = headers().get('x-tenant-id');
if (!tenantId) return null;
return db.tenant.findUnique({
where: { id: tenantId },
include: { branding: true, subscription: true }
});
});
Data Schema: Shared Database
model Tenant {
id String @id @default(cuid())
slug String @unique
name String
plan Plan @default(STARTER)
status TenantStatus @default(ACTIVE)
createdAt DateTime @default(now())
users TenantUser[]
subscription Subscription?
branding TenantBranding?
}
model User {
id String @id @default(cuid())
email String @unique
name String?
tenants TenantUser[]
}
model TenantUser {
tenantId String
userId String
role TenantRole @default(MEMBER)
joinedAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id])
user User @relation(fields: [userId], references: [id])
@@id([tenantId, userId])
}
Row-Level Security: Data Isolation
export function createTenantClient(tenantId: string) {
const client = new PrismaClient();
client.$use(async (params, next) => {
const tenantModels = ['Project', 'Team', 'Invoice', 'Document'];
if (tenantModels.includes(params.model ?? '')) {
if (params.action === 'findMany' || params.action === 'findFirst') {
params.args = params.args ?? {};
params.args.where = {
...params.args.where,
tenantId, // always filter by current tenant
};
}
if (params.action === 'create') {
params.args.data = {
...params.args.data,
tenantId, // always save with current tenantId
};
}
}
return next(params);
});
return client;
}
export default async function ProjectsPage() {
const tenant = await getCurrentTenant();
const db = createTenantClient(tenant!.id);
const projects = await db.project.findMany({
orderBy: { createdAt: 'desc' }
});
return <ProjectsList projects={projects} />;
}
Tenant Switching
export function TenantSwitcher({
currentSlug,
tenants,
}: {
currentSlug: string;
tenants: Array<{ slug: string; name: string; logoUrl?: string }>;
}) {
const router = useRouter();
const switchTenant = (slug: string) => {
const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN!;
window.location.href = `https://${slug}.${rootDomain}/dashboard`;
};
return (
<select
value={currentSlug}
onChange={e => switchTenant(e.target.value)}
>
{tenants.map(t => (
<option key={t.slug} value={t.slug}>{t.name}</option>
))}
</select>
);
}
Subdomain multi-tenant setup with Prisma middleware and wildcard SSL — 3–5 working days.







