How to Build a SaaS Product from Scratch (Technical Guide)
To build a SaaS product from scratch, you need six technical pieces working together: a multi-tenant data model, authentication, authorization that keeps each customer's data separate, subscription billing tied to what users can do, background jobs and e-mail, and a deploy pipeline with separate environments and backups. Most of the hard decisions are in the first three, so make them early and write them down. This guide goes through each piece in the order you should build it, with the schema, the trade-offs and a launch checklist.
It is written for developers starting a B2B or prosumer SaaS today. The examples use Postgres, which suits almost every SaaS, and show how each piece looks on a hosted backend (StitchDB) so you can try it as you read.
#The architecture in one picture
A typical first version of a SaaS has fewer moving parts than people expect:
| Layer | Job | Typical choice |
|---|---|---|
| Front end | Screens, forms, dashboards | Next.js, SvelteKit, Remix, or a mobile app |
| API | Read and write data | Generated REST from the schema, plus a few custom endpoints |
| Database | All business data, tenancy, permissions | Postgres with Row Level Security |
| Auth | Sign-up, sign-in, sessions | A managed auth service issuing JWTs |
| Server code | Webhooks, payments, third-party APIs | Serverless or edge functions |
| Jobs | Scheduled work, retries | Cron and a queue (pg_cron, pgmq) |
| Files | Uploads, exports | Object storage with access policies |
| Billing | Plans, checkout, invoices | Stripe |
You can build each layer yourself or get several from one backend service. The rest of this guide is about the decisions inside each layer, which are the same either way.
#Step 1: decide the tenancy model
A tenant is a paying customer: usually a company or team, not a user. Decide how tenants are separated before you write a table, because changing it later is a rewrite.
| Model | How | Pros | Cons |
|---|---|---|---|
Shared tables, org_id column | Every table has org_id; RLS filters by it | Simplest, cheapest, one migration for everyone | Isolation depends on correct policies |
| Schema per tenant | One Postgres schema per customer | Stronger separation | Migrations run N times; connection and tooling overhead |
| Database per tenant | One database or project per customer | Strongest isolation, per-customer region and backups | Highest cost and operational work |
For almost every new SaaS, start with shared tables and an org_id column. Move individual large customers to their own database later if a contract needs it. The longer comparison is in multi-tenant vs single-tenant SaaS architecture.
#Step 2: the core schema
Every SaaS has the same backbone: users, organizations, memberships, and the things organizations own.
create table public.organizations (
id uuid primary key default gen_random_uuid(),
name text not null,
plan text not null default 'free',
created_at timestamptz not null default now()
);
create table public.memberships (
org_id uuid not null references public.organizations on delete cascade,
user_id uuid not null references auth.users on delete cascade,
role text not null check (role in ('owner', 'admin', 'member')),
primary key (org_id, user_id)
);
create table public.projects (
id bigint generated always as identity primary key,
org_id uuid not null references public.organizations on delete cascade,
name text not null,
created_by uuid not null default auth.uid() references auth.users,
created_at timestamptz not null default now()
);
create index on public.projects (org_id);
create index on public.memberships (user_id);Rules that save pain later:
- Put
org_idon every tenant-owned table, even if it can be derived through a join. Policies and indexes become simple. - Use
timestamptz, nottimestamp. - Add
created_bydefaults from the session (auth.uid()) instead of trusting the client. - Keep billing state (
plan) on the organization, not the user.
On StitchDB you can skip writing the first two tables: turning on Authentication › Organizations installs organizations, members, roles and e-mail invites, with JWT claims and policy helpers such as auth.is_member(org_id) (organizations).
#Step 3: authentication
Use a managed auth service. Building password storage, e-mail confirmation, password resets, OAuth, MFA and session refresh yourself is weeks of work with little upside.
What to support at launch:
- E-mail and password, plus a magic link or one-time code for people who forget passwords.
- Google sign-in for B2B (most work accounts are Google or Microsoft).
- MFA for admins, at least as an option.
- SSO (SAML) only when a customer asks and pays for it.
With a Supabase-compatible client, the basics are a few calls (SDK auth):
import { createClient } from 'stitchdb-js'
const stitchdb = createClient('https://<project-ref>.p.stitchdb.com', '<anon-key>')
await stitchdb.auth.signUp({ email, password, options: { emailRedirectTo: `${origin}/welcome` } })
await stitchdb.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: `${origin}/app` } })
await stitchdb.auth.signInWithOtp({ email })The access token is a JWT whose sub claim is the user id. Your database reads it with auth.uid(), which is what makes the next step work.
#Step 4: authorization with Row Level Security
Authorization is where SaaS products leak data. Put the rule in the database, so it applies to every query from every client, including the ones you have not written yet.
alter table public.projects enable row level security;
create policy "members read projects" on public.projects
for select to authenticated
using (org_id in (select org_id from public.memberships where user_id = (select auth.uid())));
create policy "admins write projects" on public.projects
for all to authenticated
using (org_id in (select org_id from public.memberships
where user_id = (select auth.uid()) and role in ('owner', 'admin')))
with check (org_id in (select org_id from public.memberships
where user_id = (select auth.uid()) and role in ('owner', 'admin')));
alter table public.memberships enable row level security;
create policy "see own memberships" on public.memberships
for select to authenticated using (user_id = (select auth.uid()));
-- No insert policy: members are added by an invite function, not by clients.Three habits matter more than any specific policy: enable RLS on every table in the same migration that creates it, never let ordinary users write the memberships table, and test every policy as two different users. The common failure patterns are collected in reviewing AI-generated RLS policies.
#Step 5: billing
Use Stripe Checkout for the payment page and the Customer Portal for plan changes and invoices. Your job is the part in between: knowing what each organization has paid for.
The flow:
- The user clicks Upgrade. A server function creates a Checkout Session with your org id in
metadataand redirects. - Stripe sends webhooks (
checkout.session.completed,customer.subscription.updated,customer.subscription.deleted,invoice.payment_failed). - A webhook handler verifies the signature and updates the organization's plan and status.
- Your app and your policies read the plan, never the client.
Webhook handlers are where billing bugs live: events arrive out of order and more than once. Store the event id and ignore repeats, and never let an older event overwrite newer state.
If you would rather not write that handler, StitchDB's Stripe sync (Pro plan and up) keeps customers, prices, subscriptions and invoices in a stripe schema, applies each event once, never lets an older event overwrite newer data, and gives you a helper for policies (Stripe sync):
create policy "paid orgs export" on public.exports
for insert to authenticated
with check (stripe.has_active_subscription(auth.uid()));#Step 6: background jobs and e-mail
You will need scheduled work (trial reminders, usage rollups, cleanup) and retryable work (sending e-mail, calling slow APIs). Both can live in Postgres at the start:
-- every night at 03:00 UTC
select cron.schedule('expire-trials', '0 3 * * *',
$$ update public.organizations set plan = 'free'
where plan = 'trial' and created_at < now() - interval '14 days' $$);
-- a durable queue for outgoing e-mail
select pgmq.create('emails');
select pgmq.send('emails', '{"to": "[email protected]", "template": "welcome"}');A worker (an Edge Function on a schedule, for example) reads the queue with a visibility timeout, sends, and deletes or archives the message; anything not finished becomes visible again and is retried (cron, queues). Move to a dedicated job system only when Postgres becomes the bottleneck.
#Step 7: environments, migrations and deploys
Set this up in week one, not after the first outage:
- Local: a full copy of the backend on your machine, rebuilt from migrations and seed data.
- Preview: a separate database per pull request, so schema changes are tested before merge.
- Production: changed only by migrations from your main branch.
Keep every schema change in a SQL migration file in the repository. With the StitchDB CLI that looks like:
stitchdb init
stitchdb start # local backend, no Docker
stitchdb migration new create_projects # write SQL
stitchdb db reset # rebuild local from migrations + seed
stitchdb db push # apply to the linked project (asks first)The GitHub App gives every pull request a preview branch with its migrations applied and pushes migrations to production on merge (GitHub integration).
#Step 8: backups, monitoring and security basics
- Backups and point-in-time recovery. Daily backups are not enough for a SaaS: a bad deploy at 14:00 should be undone to 13:59. Check that your host includes point-in-time recovery and that you have tried a restore.
- Error tracking and logs. You need to see failed requests and function errors before customers report them.
- Secrets. API keys live in your function environment, never in the repository or in front-end variables. The service_role or admin key never reaches a browser.
- Rate limits on sign-in and expensive endpoints.
- An audit trail of admin actions for B2B customers who will ask.
#What it costs to run the first version
For a typical early SaaS, the backend is a small part of the bill if you choose a plan with predictable limits. On StitchDB, as of November 2026, Free covers development, and Pro at $15.99 a month includes a 10 GB database, 100 GB of files, 100,000 monthly active users, daily backups, 7 days of point-in-time recovery, preview branches and Stripe sync, with a spend cap on by default (pricing). The trade-offs: hosting is EU only, and it is a younger platform than the large incumbents.
#Launch checklist
- Every table in exposed schemas has RLS enabled and tested as two users.
- Memberships and roles cannot be changed by ordinary users.
- Billing state comes only from verified webhooks.
- Migrations are in the repository; production is changed only by them.
- A restore has been tested.
- Sign-up, sign-in, password reset and invite e-mails work from a real inbox.
- Errors and failed jobs alert someone.
- No secret key exists in front-end code or the repository.
- Terms, privacy policy and a data processing agreement are ready for B2B customers.
- You can delete a customer's data on request.
#FAQ
#What is the best tech stack for a SaaS?
A web framework you already know, Postgres for data, a managed auth service, Stripe for billing, and serverless functions for the rest. The stack matters less than getting tenancy, authorization and billing state right.
#How long does it take to build a SaaS MVP?
With a backend service that gives you auth, an API and storage, a focused MVP is often a few weeks of work for one developer. Most of the time goes into the core feature and billing edge cases, not infrastructure.
#Should each customer get their own database?
Usually not at the start. Shared tables with an org_id column and Row Level Security are simpler and cheaper. Give a customer their own database when a contract, a region requirement or their size calls for it.
#Can I build a SaaS with AI coding tools?
Yes, and many people do. Give the tool a written spec and rules, keep schema changes in reviewed migrations, and connect it to your database through a scoped, read-only connection. See how to build a SaaS with Claude Code.
Build the backend in an afternoon: create a free project and follow the quickstart.