Building a SaaS Platform from Scratch: Architecture Decisions That Matter
We have built multiple SaaS platforms from scratch. Trackelio is a customer feedback platform for product teams. LancerSpace is an all-in-one workspace for freelancers. MindHyv is a business platform that bundles social, booking, invoicing, and selling for entrepreneurs.
Each of these products serves a different market, but the underlying architecture decisions are remarkably similar. Multi-tenancy, authentication, billing, email, background jobs, deployment. Get these right early and everything else is easier. Get them wrong and you are rebuilding foundations while trying to ship features.
Here are the decisions that matter most, based on what we have actually built.
Multi-Tenancy: One Database, Isolated Data
The first architecture decision for any SaaS platform is how you isolate tenant data. There are three common patterns:
- Separate databases per tenant. Maximum isolation, maximum operational pain.
- Shared database, separate schemas. Good isolation, moderate complexity.
- Shared database, shared schema, tenant ID column. Simple to build, requires discipline.
We use option 3 for every SaaS product we build. A shared database with a tenant_id (or workspace_id, or organization_id) column on every table that holds tenant data.
Here is what the schema looks like in practice:
-- Core tenant table
create table workspaces (
id uuid primary key default gen_random_uuid(),
name text not null,
slug text unique not null,
plan text not null default 'free',
stripe_customer_id text,
created_at timestamptz default now()
);
-- Every tenant-scoped table has workspace_id
create table projects (
id uuid primary key default gen_random_uuid(),
workspace_id uuid not null references workspaces(id),
name text not null,
status text not null default 'active',
created_at timestamptz default now()
);
-- Row Level Security enforces isolation
alter table projects enable row level security;
create policy "tenant_isolation" on projects
using (workspace_id = current_setting('app.current_workspace_id')::uuid);
The key insight is that you enforce isolation at the database level, not the application level. With Supabase and PostgreSQL Row Level Security (RLS), we can guarantee that a query from Tenant A will never return Tenant B’s data, even if the application code has a bug.
When we built LancerSpace, every table — contacts, proposals, invoices, projects, time entries — carries a workspace_id. The RLS policies are set once and enforced automatically. No developer has to remember to add WHERE workspace_id = ? to every query.
Why not separate databases? Operational complexity. With a shared database, you run one migration, one backup, one connection pool. With separate databases, everything multiplies by the number of tenants. For most SaaS products under 10,000 tenants, the shared approach works perfectly.

Authentication and Authorization
Auth is the second decision that compounds. We use Supabase Auth for every SaaS product because it handles the hard parts — email/password, magic links, OAuth providers, session management, token refresh — and it integrates natively with PostgreSQL RLS.
But authentication (who are you?) is only half the problem. Authorization (what can you do?) is where the real complexity lives.
We use a simple role-based model with three layers:
// types/auth.ts
type WorkspaceRole = 'owner' | 'admin' | 'member' | 'viewer';
interface WorkspaceMember {
userId: string;
workspaceId: string;
role: WorkspaceRole;
}
// Permission check helper
const permissions: Record<WorkspaceRole, string[]> = {
owner: ['*'],
admin: ['manage:members', 'manage:billing', 'manage:projects', 'write', 'read'],
member: ['manage:projects', 'write', 'read'],
viewer: ['read'],
};
function hasPermission(role: WorkspaceRole, action: string): boolean {
const rolePerms = permissions[role];
return rolePerms.includes('*') || rolePerms.includes(action);
}
On the database side, we enforce this with RLS policies that check the user’s role:
create table workspace_members (
user_id uuid references auth.users(id),
workspace_id uuid references workspaces(id),
role text not null default 'member',
primary key (user_id, workspace_id)
);
-- Only admins and owners can delete projects
create policy "admin_delete_projects" on projects
for delete using (
exists (
select 1 from workspace_members
where workspace_members.workspace_id = projects.workspace_id
and workspace_members.user_id = auth.uid()
and workspace_members.role in ('owner', 'admin')
)
);
This approach handles 90% of SaaS authorization needs. We have used it on Trackelio (where product managers and team members have different access levels) and MindHyv (where business owners manage their team’s access to different modules).
For the other 10% — field-level permissions, conditional access based on subscription plan, resource-specific overrides — we add application-level checks. But the RLS layer is always the foundation.
Billing with Stripe
Every SaaS needs billing. We use Stripe for every product because its API is the most complete and reliable payment infrastructure available.
The architecture pattern we follow:
- Create a Stripe customer when the workspace is created. Not when they upgrade. Store the
stripe_customer_idon the workspace record. - Use Stripe Checkout for subscription management. Do not build your own payment form. Stripe Checkout handles card collection, SCA compliance, and is PCI compliant out of the box.
- Sync plan status via webhooks. Never trust the client. The source of truth for “is this workspace on the Pro plan” lives in Stripe, synced to your database via webhooks.
// api/webhooks/stripe.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function handleStripeWebhook(request: Request) {
const sig = request.headers.get('stripe-signature')!;
const body = await request.text();
const event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
const plan = subscription.items.data[0].price.lookup_key;
await db
.update(workspaces)
.set({ plan, stripeSubscriptionId: subscription.id })
.where(eq(workspaces.stripeCustomerId, subscription.customer as string));
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await db
.update(workspaces)
.set({ plan: 'free', stripeSubscriptionId: null })
.where(eq(workspaces.stripeCustomerId, subscription.customer as string));
break;
}
}
return new Response('ok', { status: 200 });
}
The mistake we see most often: building a billing system that checks the plan at the API level but not at the database level. If a workspace downgrades from Pro to Free, they should not be able to access Pro features even if they somehow craft the right API call. We add plan-based restrictions in RLS policies where possible:
-- Only pro workspaces can create more than 3 projects
create policy "plan_limit_projects" on projects
for insert with check (
(select plan from workspaces where id = workspace_id) = 'pro'
or (select count(*) from projects where workspace_id = projects.workspace_id) < 3
);

Email: Transactional First, Marketing Later
SaaS platforms need to send email. Password resets, invitation links, invoice receipts, notification digests. We use a layered approach:
Transactional email (user-triggered, must arrive immediately): We use Resend. Its API is clean, it handles deliverability well, and it integrates with React Email for templating.
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
async function sendInviteEmail(to: string, workspaceName: string, inviteUrl: string) {
await resend.emails.send({
from: 'Threshline <notifications@threshline.com>',
to,
subject: `You've been invited to ${workspaceName}`,
html: `
<p>You have been invited to join <strong>${workspaceName}</strong>.</p>
<p><a href="${inviteUrl}">Accept invitation</a></p>
`,
});
}
Notification emails (system-triggered, can be batched): We queue these and send them in digests. Nobody wants 15 individual emails about project updates. We batch them into a daily or weekly digest, configurable per user.
Marketing email (newsletters, announcements): We keep this completely separate from the transactional system. Different sending domain, different service. Mixing transactional and marketing email on the same domain is how you end up in spam folders.
Background Jobs
Every SaaS platform needs to run work outside the request/response cycle. PDF generation, webhook delivery, email sending, data aggregation, scheduled reports.
For our stack (Supabase + serverless), we use a combination of approaches:
Database-triggered functions for simple reactions. When an invoice is created, generate the PDF. Supabase Edge Functions triggered by database webhooks handle this pattern well.
Cron jobs for scheduled work. Daily digest emails, weekly usage reports, subscription renewal checks. We use either Supabase’s built-in cron (pg_cron) or an external scheduler.
-- pg_cron: send daily digest at 8am UTC
select cron.schedule(
'daily-digest',
'0 8 * * *',
$$select net.http_post(
'https://your-project.supabase.co/functions/v1/daily-digest',
'{}',
'application/json',
array[http_header('Authorization', 'Bearer ' || current_setting('app.service_key'))]
)$$
);
Queue-based processing for heavy or unreliable work. If we need to call a third-party API that might be slow or fail, we put the job in a queue (a simple Postgres table works fine for most scales) and process it with retries.
We deliberately avoid over-engineering the job system early on. A Postgres table with status columns (pending, processing, completed, failed) and a cron-triggered processor handles thousands of jobs per day. You do not need Redis, RabbitMQ, or a dedicated job framework until you are well past that scale.

Deployment and Infrastructure
Our SaaS deployment stack is intentionally boring:
- Frontend: SvelteKit or Astro, deployed to Vercel or Cloudflare Pages
- Backend/API: Supabase (PostgreSQL + Auth + Edge Functions + Storage)
- DNS and CDN: Cloudflare
- Monitoring: Sentry for errors, built-in Supabase dashboards for database metrics
We deploy to two environments: staging and production. Every merge to main deploys to staging. Production deployments are triggered manually after QA on staging.
The key architectural decision here is to keep infrastructure as managed as possible. We are a four-person studio. We do not want to manage Kubernetes clusters or patch Linux servers. Supabase manages the database. Vercel or Cloudflare manages the CDN and serverless compute. We manage the application code.
This is not laziness. It is a deliberate tradeoff. The time we save on infrastructure management goes directly into building features for our clients.
The Decisions That Compound
Looking back across Trackelio, LancerSpace, and MindHyv, the architecture decisions that had the most impact were not the flashy ones. They were the boring ones made early:
- Tenant isolation at the database level prevented entire categories of security bugs.
- Stripe webhooks as the source of truth for billing eliminated plan status inconsistencies.
- RLS policies for authorization meant every new feature automatically inherited the permission model.
- Managed infrastructure kept our operational burden close to zero.
These decisions compound. Six months in, a SaaS platform built on solid foundations is adding features quickly. One built on shaky foundations is fighting fires.
The exciting part of building SaaS is the product — the features that solve real problems for real users. The architecture should be boring enough to stay out of the way and let you focus on that.
If you are planning a SaaS product and want help with the architecture, reach out at hello@threshline.com. We have done this enough times to know where the landmines are.