Indie SaaS is mostly data discipline
Your marketing site can be beautiful. Your game hub can be cinematic. If Postgres and migrations are sloppy, launches still hurt.
This post is the checklist we wish every indie founder had before the first real user pays.
Two database URLs (and why both exist)
On Vercel-style serverless + Supabase you typically need:
| Env var | Port / mode | Used for |
|---|---|---|
DATABASE_URL | Transaction pooler (e.g. 6543 + pgbouncer=true) | App queries at runtime |
DIRECT_URL | Session / direct (e.g. 5432) | Prisma CLI migrations |
Serverless functions should not hold persistent connections like a single long-running Node server. The pooler exists so thousands of short invocations do not open thousands of real Postgres sessions.
Prisma reads both from schema.prisma:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
Rule: never run migrate dev against the pooled URL and expect happiness. Migrations need a real session.
Migration workflow that does not wreck prod
- Edit
prisma/schema.prismalocally. npm run db:migrate— creates + applies migration on dev.- Commit the migration folder.
- Deploy the app.
npm run db:migrate:prod— deploy only, never invent migrations on production.
migrate dev is for designing history. migrate deploy is for applying history you already reviewed.
If teammates or CI create conflicting migrations, stop and rebase history carefully — do not “just push another fix migration” blindly on live data.
Indexes that matter early
You do not need to index everything. You do need indexes that match real queries:
- Unique business keys:
slug,email, payment provider ids - Hot filters:
status + publishedAt,userId + createdAt - Join helpers that show up in slow query logs
For a blog, @@index([status, publishedAt]) is boring and correct. For orders, indexes on userId and Razorpay ids save support tickets.
Decimal, Date, and JSON gotchas
- Decimal (prices) is not a JS number. Serialize with
Number(...)(carefully) or strings when sending to the client. - Date becomes ISO strings across the RSC → client boundary — design types accordingly.
- Json fields are flexible until you need to query inside them often. Prefer real columns for anything you filter on.
Seeding without lying to yourself
Seed scripts should:
- Be idempotent (
upsertby slug/id) - Run against an explicit
dev|prodargument - Never hardcode production secrets in the repo
- Leave optional media null if you will upload later in admin (exactly how our blog seed works)
Ops habits that prevent 2am pages
prisma generatein build — cold deploys without a client are comic and sad.- Connection limits — pooler URLs often need
connection_limit=1per serverless instance. - Backups / point-in-time — turn them on before you need them.
- Read logs when migrate fails — “migration applied but client outdated” is a generate/deploy order problem.
- Test one write path in staging — create order, publish post, grant access — before announcing.
A minimal “am I ready?” checklist
- Local migrate works
- Prod migrate deploy is documented
-
DATABASE_URLandDIRECT_URLboth set on Vercel - Critical unique constraints exist
- Seed + admin path verified on a non-prod DB
- You know how to roll forward (preferred) when a migrate fails mid-way
Closing
Postgres will not make your product for you. But a calm Prisma workflow will give you the confidence to ship features instead of fearing every schema change.
When you are ready to practice on a full product shape, grab a Desi Stack project and walk the migrations yourself — muscle memory beats screenshots.
Keep building
Related reads
More posts coming soon.