The 2 AM Incident: When Data Barriers Collapse
The phone vibrated violently on the desk in the middle of the night. My boss sent a screenshot from a VIP client with a brief message: “Why can they see their competitor’s revenue?”. Cold sweat started to pour. This is the ultimate nightmare when running a SaaS: Cross-tenant data leaks.
My old system ran the traditional way. Every table had a tenant_id column, and I filtered data using WHERE clauses at the API layer. Just one minute of carelessness when writing code, or a new function forgetting the filter condition, and all of a customer’s business secrets would be exposed. Manually managing hundreds of subdomains like tenant-a.com on Nginx also quickly became a mess as we scaled from 10 to 500 customers.
The Flaw in the ‘Application Layer Filtering’ Mindset
After a sleepless night fixing bugs, I realized two fatal weaknesses in the old architecture:
- Cumbersome Subdomain Management: Manual DNS and server configurations cannot scale. It cost the operations team about 30 minutes for every new customer.
- Dependence on Human Carefulness: Data filtering relied entirely on Next.js code. If a junior developer forgot to check the
tenant_id, the consequences would be catastrophic.
To solve this once and for all, I restructured the system. I combined Next.js Middleware to handle dynamic routing and Supabase Row Level Security (RLS) to lock down data right at the database layer.
3 Multi-tenancy Models: Which is the Optimal Choice?
There are three common approaches when designing a database for SaaS:
- Database-per-tenant: Each customer gets their own DB. Extremely secure, but operating costs are sky-high, and maintaining migrations is a nightmare.
- Schema-per-tenant: One DB, multiple schemas. This approach is decent, but current ORM tools still don’t support dynamic schema switching smoothly.
- Shared Database (Row-level separation): Shared tables, distinguished by IDs. This was my choice because it saves resources and is easy to scale, provided there is a low-level security mechanism protecting it.
Practical Implementation: From Subdomain to Database
1. Handling Dynamic Subdomains with Next.js Middleware
Instead of configuring DNS for every name, I use Middleware to “intercept” requests. The system automatically identifies the tenant based on the URL the user is accessing.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(req: NextRequest) {
const url = req.nextUrl;
const hostname = req.headers.get('host');
// Extract subdomain (e.g., client-a.saas.com -> client-a)
const currentHost = hostname?.replace(`.mysaas.com`, '');
if (currentHost && !['www', 'mysaas.com'].includes(currentHost)) {
// Internal rewrite to folder structure /_tenants/[subdomain]
return NextResponse.rewrite(new URL(`/_tenants/${currentHost}${url.pathname}`, req.url));
}
return NextResponse.next();
}
With this approach, you just need to create a folder app/_tenants/[subdomain]/page.tsx. Next.js handles the rest. Onboarding a new customer now happens in an instant, fully automated.
2. Setting Up an “Impenetrable” Database
The table structure needs tight integration. Every business data table must have a tenant_id to identify the owner.
-- Corporate customer list table
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
slug TEXT UNIQUE -- unique subdomain identifier
);
-- User data table, strictly linked to a tenant
CREATE TABLE profiles (
id UUID REFERENCES auth.users ON DELETE CASCADE,
tenant_id UUID REFERENCES tenants(id),
full_name TEXT,
PRIMARY KEY (id)
);
3. Row Level Security (RLS): The Final Armor
This is the technique that helps me sleep well. Instead of hoping the Next.js code is written correctly, I force the Database to check access permissions itself. First, enable RLS:
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
Then, create a Policy so that Postgres only returns data belonging to the tenant_id of the logged-in user. A small tip is to store the tenant_id in the JWT to avoid querying the profiles table repeatedly, boosting query speed by 2-3 times.
CREATE POLICY "Tenant isolation policy"
ON profiles
FOR ALL
USING (
tenant_id = (auth.jwt() ->> 'tenant_id')::uuid
);
Now, even if you write supabase.from('profiles').select('*') and forget to add a filter, Supabase will only return the correct data for that tenant. If Tenant A tries to access Tenant B’s ID, the result will be empty. Absolute security.
While debugging complex JSON configurations from Supabase, I often use toolcraft.app (like the JSON Formatter tool) for quick checks. It’s much more convenient than opening heavy VS Code extensions when you’re in a rush.
Access Control at the Application Layer
Even though the Database is secure, we still need a check layer in the UI. When a user logs in, compare their tenant_id with the current subdomain. If an employee from Company A tries to log into Company B’s page, block them immediately.
const { data: profile } = await supabase.from('profiles').select('tenant_id').single();
if (profile?.tenant_id !== currentTenantIdFromUrl) {
return redirect('/unauthorized');
}
Conclusion: Don’t Gamble with Customer Data
Building a SaaS is not just about making features; it’s about building trust. Offloading security responsibility to the Database layer via RLS is the most sustainable way to protect data. Don’t try to reinvent the wheel. Leverage Next.js Middleware and the power of PostgreSQL on Supabase to focus on developing actual features for your customers.

