Multi-tenant from day one
Every customer's data kept apart by the database itself, with row-level security, so one missed filter in the code can't leak it.
- PostgreSQL
- Row-level security
SaaS development · for founders and product teams
I'm Sanjeev Singh, a full-stack developer building software since 2016. I architected CRUD Review, a live SaaS with a web app, one API and apps on both stores. I build multi-tenant products with subscriptions, roles and an admin console, on a stack you own. You get a fixed quote within 24 hours.

01The usual problem
The demo works. Turning it into a product thousands of customers can pay for is where it gets hard.
Sound familiar? Tick what applies to you.
02What you get
Every customer's data kept apart by the database itself, with row-level security, so one missed filter in the code can't leak it.
4payment gateways on CRUD Review
Trials, plan changes, failed payments and cancellations change access at the right moment, and duplicate webhooks are ignored.
Owners, admins and staff, plus parent accounts with branches under them for bigger customers.
The product your customers log in to, and a console for you to see accounts, fix problems and help users without touching the database.
Emails, imports, exports and reports run as background jobs, so pages stay fast as accounts grow.
Sign-ups, activation, trial conversion and churn tracked from launch, so pricing and roadmap decisions come from data.
03What changes
A SaaS customers can sign up to, pay for and trust with their data.
04Overview
SaaS development is building software that many customers use from one codebase, usually paid for by subscription. Sanjeev Singh is a full-stack developer who builds SaaS products end to end: the database, the API, the web app, the admin console, billing and, when it's needed, a mobile app. He has been building software since 2016, and every project starts with a fixed written quote within 24 hours.
A SaaS is harder than a normal web app in three places. First, tenancy: every customer's data has to stay apart, so a bug in one query can never show one company another company's records. Sanjeev puts that rule in the database itself, with PostgreSQL row-level security, not only in the app code. Second, billing: trials, upgrades, downgrades, failed cards and cancellations all have to change what a customer can do, at the right moment, even when a payment webhook arrives twice. Third, roles: owners, admins, staff and, for bigger customers, a parent account with many locations under it.
His main proof is CRUD Review, a SaaS he architected from an empty repository and then led as a team of four to five. One Node.js and PostgreSQL API serves a Next.js web app and a Flutter app on the App Store and Google Play. It has subscriptions with a free trial through both stores, single businesses, enterprises with outlets under them, a staff mode, an owner console and support tickets.
The usual stack is Node.js or Laravel with PostgreSQL, Next.js or React for the web app, Stripe for web billing, and a background job queue for emails, imports and reports. The reason for each choice is written in the quote. Version one is scoped to what your first paying customers need, with a written list of what comes later, so the product launches while your runway still has room.
Compared with an agency, one senior developer owns the whole system, so nothing gets lost between a sales team, a designer and three developers. Compared with hiring in-house, you get someone who has already shipped a SaaS, without a recruiting process. Either way the code, the repository and every account are in your company's name from the first day.
He works with founders and product teams in the US, the UK, the UAE and Australia. You get an NDA if you want one, a contract that assigns all code to your company, and milestone payments in US dollars, each released after you've seen that stage working.
05The blueprint
Switch between launch, grow and scale to see what goes into each stage, and click any part for the detail. Packets show how data moves.
Customers
Each customer is a tenant: a company with its own users, data and plan. Sign-up creates the tenant, the first owner and a trial in one step.
Launch
Your team
You and your support staff, with their own logins and permissions, separate from customer accounts.
Launch
SSO & audit log
What larger customers ask for before they sign: single sign-on with their identity provider and a log of who changed what.
ScaleSAMLOIDC
Web app
The product your customers log in to: the core workflow, team invites, settings and billing pages, fast on any device.
LaunchNext.jsReact
Admin console
Look up any account, see its plan and activity, extend a trial or fix data without opening the database.
Launch
Mobile app
Added when customers use the product on the move. It talks to the same API, so there is no second backend to maintain.
GrowFlutter
API & auth
One API for the web app, the admin console and the mobile app. Every request carries a verified tenant, and every query runs inside it.
LaunchNode.jsLaravel
Billing
Plans, trials, upgrades, failed payments and cancellations. Signed webhooks update access, and each event is recorded once so retries are harmless.
LaunchStripeApp stores
PostgreSQL
One database with a tenant id on every row and policies that enforce it. Indexed for the queries the product actually runs, with backups from day one.
LaunchRow-level security
Background jobs
Imports, exports, reports and scheduled tasks run in a queue with retries, so a slow job never slows a page.
Grow
Email & alerts
Invites, receipts, trial reminders and failed-payment emails, sent from the queue so nothing is lost if a provider is briefly down.
Grow
Metrics
Sign-up, activation and payment events from the first day, plus error tracking, so you know what users do and what breaks.
Launch
Customers
LaunchEach customer is a tenant: a company with its own users, data and plan. Sign-up creates the tenant, the first owner and a trial in one step.
Your team
LaunchYou and your support staff, with their own logins and permissions, separate from customer accounts.
Web app
LaunchThe product your customers log in to: the core workflow, team invites, settings and billing pages, fast on any device.
Admin console
LaunchLook up any account, see its plan and activity, extend a trial or fix data without opening the database.
API & auth
LaunchOne API for the web app, the admin console and the mobile app. Every request carries a verified tenant, and every query runs inside it.
Billing
LaunchPlans, trials, upgrades, failed payments and cancellations. Signed webhooks update access, and each event is recorded once so retries are harmless.
PostgreSQL
LaunchOne database with a tenant id on every row and policies that enforce it. Indexed for the queries the product actually runs, with backups from day one.
Metrics
LaunchSign-up, activation and payment events from the first day, plus error tracking, so you know what users do and what breaks.
Mobile app
GrowAdded when customers use the product on the move. It talks to the same API, so there is no second backend to maintain.
Background jobs
GrowImports, exports, reports and scheduled tasks run in a queue with retries, so a slow job never slows a page.
Email & alerts
GrowInvites, receipts, trial reminders and failed-payment emails, sent from the queue so nothing is lost if a provider is briefly down.
SSO & audit log
ScaleWhat larger customers ask for before they sign: single sign-on with their identity provider and a log of who changed what.
06Every layer
The one rule a SaaS can't break is that customers never see each other's data. Here is how I enforce it at every layer, in real code.
migrations/002_projects.sqlsql
create table projects (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references tenants (id),
name text not null,
created_at timestamptz not null default now()
);
create index on projects (tenant_id, created_at desc);
alter table projects enable row level security;
alter table projects force row level security; -- the table owner too
-- current_setting() errors when unset, so a missing tenant fails closed.
create policy tenant_isolation on projects
using (tenant_id = current_setting('app.tenant_id')::uuid)
with check (tenant_id = current_setting('app.tenant_id')::uuid);Why:Even if a query in the app forgets its WHERE clause, PostgreSQL won't return another tenant's rows. If no tenant is set, the query fails instead of returning everything.
src/db/tenant.tsts
import type { PoolClient } from 'pg';
import { pool } from './pool';
// Every query for a request runs in one transaction scoped to its tenant.
export async function withTenant<T>(tenantId: string, work: (db: PoolClient) => Promise<T>) {
const db = await pool.connect();
try {
await db.query('begin');
await db.query("select set_config('app.tenant_id', $1, true)", [tenantId]); // true = this transaction only
const result = await work(db);
await db.query('commit');
return result;
} catch (err) {
await db.query('rollback');
throw err;
} finally {
db.release();
}
}
// In a route: no tenant filter to forget.
app.get('/projects', requireAuth, async (req, res) => {
const { rows } = await withTenant(req.user.tenantId, (db) =>
db.query('select id, name from projects order by created_at desc'),
);
res.json(rows);
});Why:The tenant comes from the verified session, never from the request body. The setting lasts only for the transaction, so a pooled connection can't carry it into the next request.
src/billing/webhook.tsts
import express from 'express';
import Stripe from 'stripe';
import { pool } from '../db/pool';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export const webhook = express.Router();
// Raw body: the signature covers the exact bytes Stripe sent.
webhook.post('/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature']!, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return res.status(400).send('Bad signature');
}
const db = await pool.connect();
try {
await db.query('begin');
// A retried event inserts nothing, so it changes nothing.
const { rowCount } = await db.query('insert into stripe_events (id) values ($1) on conflict do nothing', [event.id]);
if (rowCount && event.type.startsWith('customer.subscription.')) {
const sub = event.data.object as Stripe.Subscription;
await db.query('update tenants set plan_status = $1 where stripe_customer_id = $2', [sub.status, sub.customer]);
}
await db.query('commit');
} catch {
await db.query('rollback');
return res.sendStatus(500); // Stripe retries later
} finally {
db.release();
}
res.sendStatus(200);
});Why:Stripe signs the raw request body and retries failed deliveries, so the handler checks the signature first and records each event id once, in the same transaction as the change it makes.
test/tenant-isolation.test.tsts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { withTenant } from '../src/db/tenant';
import { seedTenant } from './helpers';
test("a tenant never sees or writes another tenant's rows", async () => {
const a = await seedTenant({ projects: ['Alpha'] });
const b = await seedTenant({ projects: ['Bravo'] });
const { rows } = await withTenant(a.id, (db) => db.query('select name from projects'));
assert.deepEqual(rows.map((r) => r.name), ['Alpha']);
await assert.rejects(
withTenant(a.id, (db) =>
db.query('insert into projects (tenant_id, name) values ($1, $2)', [b.id, 'Sneaky']),
),
/row-level security/,
);
});Why:The test runs as the same non-superuser role the app uses, because superusers skip row-level security. If anyone weakens the policy, this fails before it ships.
07Ways to work together
Scope, build and launch: sign-up, the core workflow, billing, roles and an admin console, on your own accounts.
Stripe plans, trials, upgrades and dunning added to an existing product, with webhooks that survive retries.
An app built for one client turned into a product for many, with data isolation enforced in the database.
Account lookup, plan changes, impersonation with an audit trail, and the reports your team keeps asking for.
Code someone else started, reviewed, documented and improved, without stopping the customers who already pay.
Slow pages, timeouts and growing bills traced to the query, index or job that causes them, then fixed.
08Proof
09In their words
Rated on Fiverr
5.0
23 reviewsRead them
Sanjeev has been instrumental across several projects. He understands the vision from very little direction, then brings commitment, technical skill and original thinking to turn it into a finished product.
Sanjeev brought extraordinary technical knowledge to the project. He communicated professionally, shared clear updates, discussed every hurdle openly and always found a practical way forward. I was very happy working with him.
Great job again.
Working with Sanjeev was a great experience.
01 / 04
10Your time, my time
Your time · New York
--:--
My time · Lucknow, India
--:--
Your office hours (9 AM–6 PM)When I'm on calls and chat
11Your options
Who builds it
Working with meA senior developer who has shipped a SaaS
Time to start
Working with meDays, after a fixed quote
Tenancy and billing
Working with meDesigned in from the first commit
Who owns the code
Working with meYou, with every login, from day one
Version two
Working with meBuilt on the same code
| Working with me | A software agency | Hiring in-house | |
|---|---|---|---|
| Who builds it | A senior developer who has shipped a SaaS | A team, after sales hands over | Whoever you can recruit |
| Time to start | Days, after a fixed quote | Weeks of discovery meetings | Months to hire and onboard |
| Tenancy and billing | Designed in from the first commit | Depends on the team you get | Depends on who you hire |
| Who owns the code | You, with every login, from day one | Sometimes the agency | You |
| Version two | Built on the same code | A new contract | Your team, if they stay |
12Free guide
Most SaaS products launch late because version one tries to be version three. These are the parts I build first, and the parts I write down for later.
| One database, tenant id on every row | A schema per tenant | A database per tenant | |
|---|---|---|---|
| Isolation | Enforced by row-level security | Strong | Strongest |
| Cost to run | Lowest | Medium | Highest |
| Schema changes | Once | Once per tenant | Once per tenant |
| Best for | Most B2B SaaS | A few large customers | Strict compliance or data residency |
For most products I start with one database and row-level security, and keep the door open to move a large customer to their own database later. If you’re still deciding what to build first, read how much an MVP costs and choosing the right stack.
13How we work
A short call on goals, audience and what success looks like.
Scope, timeline and price agreed in writing before work starts.
Working pages shared early on a staging link, refined with your feedback.
Go-live, speed and SEO checks, then handover. You own the code, domain and accounts.
14Questions
It depends on the core workflow, the number of roles and the integrations. A focused version one usually takes weeks, not months, and the written quote gives the exact timeline before any work starts. You see working builds on a staging link along the way.
It depends on scope, mostly the number of screens, roles, integrations and whether you need a mobile app. I send a fixed written quote within 24 hours of a short call, so the price is agreed before work starts.
Usually Node.js or Laravel with PostgreSQL, Next.js or React for the web app, Stripe for billing and a background queue for jobs. I pick based on your product and team, and the reason is written in the quote.
Every row carries a tenant id, and PostgreSQL row-level security policies enforce it, so the database refuses to return another tenant's data even if a query in the app is wrong. A test checks it on every change.
Yes. I add Stripe plans, trials, upgrades, failed-payment handling and cancellations, connect them to what each account can access, and make the webhooks safe to receive twice.
Yes. I start with a written review of the code, the database and the hosting, fix what's risky first, and keep the product running for the customers who already pay.
I sign an NDA before the first detailed call if you want one. The contract assigns all code to your company, and the repository, hosting and billing accounts are in your name from the start.
I invoice in US dollars, wherever you are, against milestones agreed in the quote. You don't pay for a stage until you've seen it working.
Question not here? Ask me directly.
Ask on WhatsAppLast updated
Ready when you are
Chat with Sanjeev
Tap a question or send your own.