TL;DR: This guide demonstrates how to architect a scalable multi-tenant SaaS using a single shared database to avoid connection pool exhaustion and CI/CD bottlenecks. You will learn to dynamically route wildcard subdomains using Next.js Edge Middleware, and enforce unbypassable data isolation at the database engine level using PostgreSQL Row-Level Security (RLS).
⚡ Key Takeaways
- Configure wildcard DNS records (e.g.,
CNAME * cname.vercel-dns.com.) to route arbitrary tenant subdomains to your hosting provider. - Simulate tenant subdomain requests in local development by passing custom
Hostheaders viacURL(e.g.,curl -H "Host: acme.localhost:3000"). - Intercept the
Hostheader in Next.js Edge Middleware to extract tenant slugs and dynamically map them usingNextResponse.rewrite(). - Structure your App Router to physically separate rewritten paths, isolating tenant application logic (
/app/[tenant]/*) from root marketing pages (/home/*). - Prevent cross-tenant data leaks by replacing vulnerable application-level
WHERE tenant_id = ?clauses with PostgreSQL Row-Level Security (RLS) in a single shared schema.
You're building a B2B SaaS. Your first few customers are successfully onboarded, and the product is functioning smoothly. Then, the enterprise requests start rolling in. They want their workspaces accessible via company.yoursaas.com. Some even demand fully mapped custom domains like app.theircompany.com.
Suddenly, your simple routing logic turns into a tangled mess of conditional renders. Worse, you are relying entirely on application-level WHERE tenant_id = ? clauses to prevent Customer A from seeing Customer B's data. A single missed parameter in an ORM query, and you have a catastrophic cross-tenant data leak on your hands. The conventional advice—spinning up a separate database schema for every single tenant—is going to throttle your CI/CD velocity and obliterate your database connection pool before you even hit 100 customers.
You need a unified architecture. You need a way to serve infinite subdomains and custom domains from a single deployment while enforcing bulletproof data isolation at the database engine level, not the application layer.
In this guide, we will architect a modern multi-tenant system utilizing the Next.js App Router, Edge Middleware for dynamic wildcard routing, and PostgreSQL Row-Level Security (RLS) to enforce strict, unbypassable data boundaries within a single shared database.
Foundation: Configuring Wildcard DNS and Host Headers
Before writing any Next.js code, your infrastructure must be configured to route arbitrary subdomains to your application. If your base domain is saascraft.com, you need *.saascraft.com to resolve to your hosting provider.
If you are hosting on Vercel, AWS Amplify, or a custom Nginx VPS, the DNS configuration requires a wildcard CNAME or A record.
# BIND / Cloudflare DNS Configuration
Type Name Target TTL
A @ 76.76.21.21 Auto # Root domain
CNAME * cname.vercel-dns.com. Auto # Wildcard for subdomains
Production Note: While wildcard DNS handles subdomains automatically, serving traffic over fully custom domains (e.g.,
app.clientdomain.com) requires dynamic SSL certificate generation. Providers like Vercel and Cloudflare offer APIs to provision TLS certificates on the fly when a new custom domain is attached to your project.
To test this locally without modifying your OS hosts file, you can run your Next.js dev server and pass custom Host headers via cURL or a local proxy tool.
# Simulating a tenant request locally
curl -H "Host: acme.localhost:3000" http://localhost:3000
Routing Subdomains with Next.js Edge Middleware
The Next.js App Router relies on a file-system-based routing paradigm. To dynamically serve different tenants without creating physical directories for each subdomain, we leverage Next.js Middleware.
Middleware intercepts the request at the edge, parses the Host header, identifies the tenant, and silently rewrites the URL to a dynamic route path inside your app directory.
Create a middleware.ts file in the root of your project:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export const config = {
matcher: [
/*
* Match all paths except for:
* 1. /api routes
* 2. /_next (Next.js internals)
* 3. /_static (inside /public)
* 4. all root files inside /public (e.g. favicon.ico)
*/
'/((?!api/|_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)',
],
};
export default function middleware(req: NextRequest) {
const url = req.nextUrl;
// Extract hostname from headers
const hostname = req.headers.get('host') || 'saascraft.com';
// Define allowed root domains (including localhost for dev)
const rootDomains = ['saascraft.com', 'localhost:3000'];
// Determine if this is a root domain request
const isRootDomain = rootDomains.includes(hostname);
// Extract the tenant slug (e.g., 'acme' from 'acme.saascraft.com')
// If it's a completely custom domain, we pass the full domain as the tenant identifier
const tenant = isRootDomain
? null
: hostname.replace(`.${rootDomains[0]}`, '').replace(`.${rootDomains[1]}`, '');
// Rewrite logic
if (tenant) {
// Rewrite tenant requests to /app/[tenant]/path
return NextResponse.rewrite(new URL(`/app/${tenant}${url.pathname}`, req.url));
}
// Rewrite root domain requests to /home/path
return NextResponse.rewrite(new URL(`/home${url.pathname}`, req.url));
}
This middleware effectively splits our application into two distinct zones. When providing full-stack web development services, we consistently use this pattern to separate marketing and landing pages from core SaaS application logic without maintaining two separate repositories.
Structuring the App Router for Multi-Tenancy
Because our middleware rewrites URLs, our app directory must physically match the rewritten paths. The user never sees /app/acme/dashboard in their browser; they only see acme.saascraft.com/dashboard.
Here is the required folder structure:
app/
├── (public)/ # Marketing site
│ └── home/
│ ├── page.tsx # Renders on saascraft.com
│ └── pricing/page.tsx # Renders on saascraft.com/pricing
│
└── (saas)/ # Multi-tenant App
└── app/
└── [tenant]/
├── layout.tsx # Tenant-specific navigation/auth
├── page.tsx # Renders on acme.saascraft.com
└── settings/
└── page.tsx # Renders on acme.saascraft.com/settings
Inside your app/app/[tenant]/page.tsx, you can now access the tenant identifier as a standard Next.js route parameter:
// app/app/[tenant]/page.tsx
import { notFound } from 'next/navigation';
import { getTenantByDomain } from '@/lib/db/tenant';
interface PageProps {
params: {
tenant: string; // The subdomain or custom domain
};
}
export default async function TenantDashboard({ params }: PageProps) {
const { tenant } = params;
// Resolve the tenant from the database
const tenantData = await getTenantByDomain(tenant);
if (!tenantData) {
return notFound();
}
return (
<main className="p-8">
<h1 className="text-2xl font-bold">Welcome to {tenantData.name}</h1>
{/* Render tenant-specific dashboard components */}
</main>
);
}
Bulletproof Data Isolation with PostgreSQL RLS
Routing is only half the battle. The most critical aspect of multi-tenant architecture is data isolation.
In a shared-database approach (often called "Pool-based Multi-Tenancy"), all tenants share the same tables. You differentiate their rows using a tenant_id foreign key.
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
amount DECIMAL NOT NULL,
status VARCHAR(50) NOT NULL
);
Historically, developers relied on their ORM to append .where('tenant_id', currentTenant) to every query. This is a massive security vulnerability. If a developer forgets the where clause on a single GET /api/invoices endpoint, cross-tenant data exposure occurs.
Instead, we push the security down to the database engine using PostgreSQL Row-Level Security (RLS).
First, we enable RLS on the table and create a policy that forces PostgreSQL to check a local session variable before returning or modifying any rows.
-- 1. Enable RLS on the target table
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
-- 2. Force RLS even for table owners/superusers
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
-- 3. Create a unified policy for all operations (SELECT, INSERT, UPDATE, DELETE)
CREATE POLICY tenant_isolation_policy ON invoices
AS RESTRICTIVE
FOR ALL
USING (
tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid
)
WITH CHECK (
tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::uuid
);
Warning: The
trueparameter incurrent_setting('app.current_tenant', true)is crucial. It prevents PostgreSQL from throwing a fatal error if the variable hasn't been set yet, safely returningNULLinstead (which results in zero rows returned).
With this policy active, SELECT * FROM invoices; will return zero rows unless the database connection explicitly sets the app.current_tenant variable first.
Integrating PostgreSQL RLS with Prisma and Next.js
Implementing RLS in a serverless environment like Next.js requires carefully managing database transactions. Connection poolers (like PgBouncer or Supabase's Supavisor) reuse database connections across multiple incoming requests. If you set app.current_tenant on a connection and don't clear it, the next request borrowing that connection might read the wrong tenant's data.
To solve this, we must wrap every database operation in a Transaction, set the local variable at the start of the transaction using SET LOCAL, and execute our queries. SET LOCAL guarantees the variable is scoped strictly to the current transaction and is cleared immediately upon COMMIT or ROLLBACK.
Here is how to enforce this securely using Prisma Client extensions in your Next.js API routes or Server Actions:
// lib/db/prisma.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
/**
* Creates an RLS-enforced Prisma Client scoped to a specific tenant.
*/
export const getTenantDb = (tenantId: string) => {
return prisma.$extends({
query: {
$allModels: {
async $allOperations({ args, query }) {
// Wrap the operation in an interactive transaction
return prisma.$transaction(async (tx) => {
// 1. Set the PostgreSQL session variable for this transaction ONLY
await tx.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true)`;
// 2. Execute the requested query
return await query(args);
});
},
},
},
});
};
Now, inside your Next.js Server Actions or Server Components, fetching data is completely safe. The ORM doesn't need to know about tenant_id WHERE clauses at all.
// app/app/[tenant]/invoices/page.tsx
import { getTenantDb } from '@/lib/db/prisma';
import { getTenantByDomain } from '@/lib/db/tenant';
export default async function InvoicesPage({ params }: { params: { tenant: string } }) {
const tenant = await getTenantByDomain(params.tenant);
// Initialize the RLS-secured client
const db = getTenantDb(tenant.id);
// This will ONLY return invoices for the current tenant.
// The filtering is entirely handled by PostgreSQL RLS.
const invoices = await db.invoice.findMany({
orderBy: { createdAt: 'desc' }
});
return (
<ul className="space-y-4">
{invoices.map((inv) => (
<li key={inv.id}>Invoice #{inv.id} - ${inv.amount}</li>
))}
</ul>
);
}
Automating Custom Domain Provisioning
As your B2B SaaS scales, enterprise clients will request white-labeling via their own domains (dashboard.client.com). Manually adding these to your hosting provider dashboard is not scalable.
You can automate this by integrating with the Vercel Domains API (or Cloudflare API) directly from a Next.js Server Action. When a user enters a custom domain in your settings panel, your application can programmatically attach it to your project.
// app/actions/addCustomDomain.ts
'use server';
import { prisma } from '@/lib/db/prisma';
export async function addCustomDomain(domain: string, tenantId: string) {
const VERCEL_API_TOKEN = process.env.VERCEL_API_TOKEN;
const VERCEL_PROJECT_ID = process.env.VERCEL_PROJECT_ID;
// 1. Call Vercel API to attach the domain to the project
const response = await fetch(
`https://api.vercel.com/v10/projects/${VERCEL_PROJECT_ID}/domains`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${VERCEL_API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: domain }),
}
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error?.message || 'Failed to add domain');
}
// 2. Save the domain mapping in your PostgreSQL database
// (Using the admin-level db client here to bypass RLS for infrastructure logic)
await prisma.domainMapping.create({
data: {
domain,
tenantId,
status: 'pending_verification'
}
});
return { success: true, domain: data };
}
Once the domain is mapped, your Next.js Edge Middleware will automatically pick it up, extract the identifier, and route it to the correct [tenant] folder path. The client simply needs to add the provided CNAME record to their DNS provider, and Vercel will automatically generate the SSL certificates.
We implement these exact programmatic provisioning pipelines in our work with high-growth startups, ensuring that their platforms can onboard thousands of B2B clients with zero manual DevOps intervention.
Architectural Trade-offs and Considerations
While the single-database RLS pattern is incredibly powerful, it is important to acknowledge its operational realities:
- Connection Pooling is Mandatory: Because Next.js serverless functions open and close connections rapidly, you must route your Prisma connections through a pooler like PgBouncer. Direct database connections will exhaust your limits in seconds.
- Analytics Query Performance: RLS policies run on every query. If you need to perform cross-tenant analytics (e.g., generating aggregate billing reports across all customers), you will need to bypass RLS. This is typically done by connecting using a dedicated
postgressuperuser role that bypasses policies, reserved strictly for backend cron jobs. - Caching Complexity: Next.js aggressively caches pages and fetch requests. You must include the
tenantIdin yourrevalidateTagstrategies. Never cache a global layout without segmenting it by the tenant context, or Customer A might see a cached version of Customer B's sidebar.
Scaling to Enterprise Readiness
Transitioning from a single-user application to a multi-tenant B2B platform fundamentally changes how you manage state, security, and infrastructure. By leveraging Next.js Middleware for domain rewriting and PostgreSQL RLS for database-level isolation, you create a robust system that prevents catastrophic data leaks by design, rather than relying solely on developer discipline.
If you are transitioning your product from B2C to B2B, or dealing with the technical debt of a messy multi-database architecture, book a free architecture review to talk to our backend engineers. We can help you consolidate your infrastructure, automate your tenant provisioning, and lock down your data security.
Frequently Asked Questions
Why am I seeing [object Object] in my JavaScript output?
This happens when a JavaScript object is implicitly converted to a string, usually through string concatenation or template literals. By default, the toString() method of a standard JavaScript object returns the string "[object Object]". To fix this, you need to access specific properties of the object or serialize it properly.
How can I properly display an object's contents for debugging?
You should use JSON.stringify(yourObject) to convert the object into a readable JSON string rather than relying on implicit coercion. For better readability, you can pass formatting arguments like JSON.stringify(yourObject, null, 2) to pretty-print the output with indentation.
How does SoftwareCrafting help teams eliminate type coercion bugs?
SoftwareCrafting offers comprehensive code review and refactoring services to help identify implicit type conversions before they reach production. Our experts can help your team implement strict typing with TypeScript, ensuring that objects are handled correctly and preventing unexpected rendering errors.
Why does console.log("Data: " + obj) print [object Object] but console.log(obj) works?
When you use the + operator with a string, JavaScript forces the object to become a string, triggering its default toString() method. When you pass the object as a separate argument using a comma (console.log("Data:", obj)), the browser console inspects and displays the interactive object tree instead.
How do I fix [object Object] rendering issues in React?
React does not allow rendering plain objects directly as text nodes and will fail or render [object Object] if the object is coerced into a string. You must map over the object's keys, render specific primitive properties (like user.name), or use JSON.stringify() if you are just debugging the component state.
Can SoftwareCrafting assist with migrating a codebase to TypeScript to prevent these errors?
Yes, SoftwareCrafting specializes in modernizing web applications, including seamless migrations from vanilla JavaScript to TypeScript. By introducing static typing and custom interfaces, our services ensure that object serialization and coercion issues are caught at compile time rather than in the browser.
📎 Full Code on GitHub Gist: The complete
unresolved-template.jsfrom this post is available as a standalone GitHub Gist — copy, fork, or embed it directly.
