Every developer building a SaaS faces the same moment: you sit down to start and realize you have to make 20 architectural decisions before writing a single line of product code. Framework, database, ORM, auth, payments, state management, deployment, monitoring — and each choice has downstream consequences that compound over time.
I've seen startups spend three months debating the stack and never shipping. I've also seen teams pick something fast, outgrow it at 50k users, and spend six months migrating while competitors caught up.
This guide is the distillation of those lessons. Every decision explained not just with "what" but with the honest "why" — including where each choice breaks down and when you'd choose differently.
This is part of the "Build a SaaS from $0 to Real Product" series. Each article in the series covers one layer of the stack in production depth. Start here, then follow the links at the bottom for the next layer.
The North Star: Optimize for Shipping Speed Without Accumulating Debt
Before listing tools, the principle: the best stack is the one your team ships fastest on without creating problems you can't fix later. That sounds obvious until you watch teams pick microservices for their first SaaS because "it'll scale better" and spend the first three months debugging distributed traces instead of finding their first customer.
The 2026 SaaS landscape has converged on some clear winners in each category. The choices below aren't "safe" picks — they're the ones I've seen consistently outperform alternatives in velocity, debuggability, and total cost of ownership under $10M ARR.
Runtime & Framework: Next.js 14+ App Router
The choice: Next.js with the App Router, TypeScript, deployed on Vercel.
Why not the alternatives:
Remix — excellent DX, great mental model for forms and mutations. But the ecosystem is smaller, the job market is smaller, and the App Router has caught up on most of its advantages. If you know Remix deeply, use it. If you're starting fresh, Next.js has more leverage.
SvelteKit — genuinely beautiful to write. Not the right bet if you plan to hire. React is where the talent is.
Express/Fastify + separate frontend — more flexibility, but you're now maintaining two deployments, two build systems, and two sets of dependencies. That's overhead that costs you during the months where every week matters.
What App Router actually gives you:
// This component runs on the server — no client-side waterfall,
// no loading state, no separate API call
export default async function DashboardPage() {
// Direct database access, no API layer needed
const user = await db.query.users.findFirst({
where: eq(users.id, auth().userId),
with: { subscription: true, projects: true },
})
if (!user) redirect('/sign-in')
return <Dashboard user={user} />
}You eliminated an entire request waterfall. The data is there when the HTML renders. That's not a marginal improvement — it changes the perceived performance of your app fundamentally.
If you're new to React Server Components, budget two weeks of confusion. The mental model shift — knowing what runs on server vs client, what can be async, when to use use client — takes time to internalize. It's worth it, but don't underestimate it.
Database: PostgreSQL on Neon
The choice: PostgreSQL, hosted on Neon (serverless Postgres).
Why PostgreSQL over everything else:
PostgreSQL is the most battle-tested relational database that exists. 30 years of development, ACID compliance, full-text search, JSON columns, extensions (pgvector for AI embeddings, PostGIS for geo), and the widest ecosystem of tooling.
MySQL is fine. SQLite is great for local dev and small apps. MongoDB made sense when schemas were truly unknown — which is rarely the case in a SaaS where you have users, subscriptions, and projects.
Why Neon specifically:
- Serverless scaling — scales to zero when nobody's using your app (important for pre-revenue)
- Branch databases — create a full copy of production for each PR, run migrations safely, delete when merged. This alone is worth it.
- Compatible with every Postgres tool — Drizzle, Prisma, psql, pgAdmin, all work unchanged
- Generous free tier — $0 until you're making money
Alternatives: Supabase (PostgreSQL + realtime + storage in one — good if you need those extras), PlanetScale (MySQL, was excellent, now paid-only), Railway (simpler, less features).
ORM: Drizzle
The choice: Drizzle ORM over Prisma.
This is the most contested decision in 2026, so let me be precise about why.
Prisma was the default for years. It has excellent DX, a great schema language, and generates a type-safe client. It's still a solid choice.
Drizzle wins on three specific things:
-
Performance at scale. Drizzle is a thin layer over SQL. It doesn't abstract the query — it types it. Under load, this matters. Prisma generates queries you can't always predict; Drizzle generates exactly what you write.
-
Edge Runtime compatibility. Drizzle works in Next.js middleware, Cloudflare Workers, and any Edge Runtime. Prisma's query engine is a Rust binary — it doesn't run at the edge.
-
SQL when you need it. Drizzle lets you drop to raw SQL at any point without leaving the type system. Prisma's escape hatch (
$queryRaw) loses type safety.
import { pgTable, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: text('id').primaryKey(), // Clerk user ID
email: text('email').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
export const subscriptions = pgTable('subscriptions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
stripeCustomerId: text('stripe_customer_id').notNull(),
stripePriceId: text('stripe_price_id').notNull(),
status: text('status').notNull(), // 'active' | 'canceled' | 'past_due'
currentPeriodEnd: timestamp('current_period_end').notNull(),
})import { db } from './index'
import { users, subscriptions } from './schema'
import { eq, and, gt } from 'drizzle-orm'
// Fully typed — no casting, no guessing
export async function getActiveSubscription(userId: string) {
return db.query.subscriptions.findFirst({
where: and(
eq(subscriptions.userId, userId),
eq(subscriptions.status, 'active'),
gt(subscriptions.currentPeriodEnd, new Date())
),
})
}When to keep Prisma: if your team already knows it deeply, or if you're not running anything at the edge. Switching for switching's sake is waste.
Auth: Clerk
The choice: Clerk, not NextAuth, not Auth.js, not rolling your own.
This is the one I feel most strongly about. Authentication is one of those things that seems simple and is genuinely not.
What Clerk handles that you'd otherwise build:
- Sign up, sign in, MFA, magic links, social OAuth (Google, GitHub, etc.)
- Session management across devices, session revocation
- Organization/multi-tenancy (users can belong to multiple orgs with different roles)
- User management dashboard (you can impersonate users, ban accounts, manage metadata)
- Webhooks for user lifecycle events (user.created, user.deleted)
- GDPR compliance, SOC 2 Type II certified
The cost: $25/month for 10k MAU. Free under 10k. For a SaaS, auth is not where you optimize costs.
The middleware:
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
const isPublicRoute = createRouteMatcher([
'/',
'/pricing',
'/blog(.*)',
'/api/webhook(.*)', // Stripe and Clerk webhooks must be public
'/sign-in(.*)',
'/sign-up(.*)',
])
export default clerkMiddleware(async (auth, req) => {
if (!isPublicRoute(req)) {
await auth.protect()
}
})
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)', '/(api|trpc)(.*)'],
}In Server Components:
import { auth, currentUser } from '@clerk/nextjs/server'
export default async function SettingsPage() {
const { userId } = await auth()
if (!userId) redirect('/sign-in')
const user = await currentUser()
return <Settings user={user} />
}In your database, use Clerk's userId (a string like user_2abc...) as the primary key for your users table and as the foreign key in every related table. Don't generate your own UUID — Clerk's ID is stable and already unique.
Payments: Stripe
There is no serious alternative. Stripe is the only choice for a SaaS in 2026 if you want to ship without thinking about payments infrastructure.
The non-obvious things Stripe handles: tax calculation (Stripe Tax), invoicing, payment method management, dunning (failed payment retries), SCA compliance for EU customers, dispute management.
The hardest part of Stripe integration isn't the payment — it's the webhook handling. Stripe sends webhooks for every state change, and you need to process them idempotently:
import Stripe from 'stripe'
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { subscriptions } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const body = await req.text()
const signature = headers().get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription
await db
.insert(subscriptions)
.values({
id: sub.id,
userId: sub.metadata.userId, // Set this when creating the checkout session
stripeCustomerId: sub.customer as string,
stripePriceId: sub.items.data[0].price.id,
status: sub.status,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
})
.onConflictDoUpdate({
target: subscriptions.id,
set: {
status: sub.status,
stripePriceId: sub.items.data[0].price.id,
currentPeriodEnd: new Date(sub.current_period_end * 1000),
},
})
break
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription
await db
.update(subscriptions)
.set({ status: 'canceled' })
.where(eq(subscriptions.id, sub.id))
break
}
}
return NextResponse.json({ received: true })
}Stripe can deliver the same webhook event more than once. Your handlers must be idempotent — processing the same event twice should produce the same result as processing it once. The onConflictDoUpdate pattern above handles this. Without it, you'll have race conditions and duplicate records in production.
API Layer: tRPC for Internal, REST for External
If your frontend and backend are in the same Next.js project and you control both, tRPC is the right choice. It eliminates the API contract problem entirely — the server defines the procedure, the client calls it, TypeScript handles the rest.
import { z } from 'zod'
import { router, protectedProcedure } from '../trpc'
export const projectsRouter = router({
list: protectedProcedure.query(async ({ ctx }) => {
return ctx.db.query.projects.findMany({
where: eq(projects.userId, ctx.userId),
orderBy: [desc(projects.createdAt)],
})
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
}))
.mutation(async ({ input, ctx }) => {
// input is typed — no casting, no runtime surprises
return ctx.db.insert(projects).values({
...input,
userId: ctx.userId,
}).returning()
}),
})If you need a public API (third parties, mobile apps), use REST with Hono + Zod validation. tRPC's client is TypeScript-only — it's not suitable for external consumers.
State Management: Server First, Zustand for the Rest
The App Router changes the state management equation significantly. Most data that would have been in Redux or React Query is now server state — fetched in Server Components, never touching the client at all.
The hierarchy:
- Server Components — async data fetching, no loading state, no client bundle
- TanStack Query — when you need client-side data that stays fresh (real-time-ish updates, mutations with cache invalidation)
- Zustand — global UI state only: sidebar open/closed, modal state, toast queue, theme. Keep stores small.
Avoid: Redux (overkill), Context for everything (performance issues at scale), server state in Zustand (you'll fight cache invalidation forever).
Deployment: Vercel
Vercel is the path of least resistance for Next.js and it's genuinely good:
- Preview deployments on every PR (show stakeholders, QA before merge)
- Edge Network CDN out of the box
- Automatic HTTPS, custom domains
- Environment variables per environment (preview/production)
- Logs, analytics, Web Vitals monitoring in the dashboard
Cost at scale: Vercel gets expensive above ~$200/month. At that point ($30-50k MRR), you have the revenue to optimize. Railway and Fly.io are the migration targets.
Observability: The Minimum Viable Stack
Shipping without observability is flying blind. The minimum you need from day 1:
// Error tracking: Sentry (free tier covers 5k errors/month)
import * as Sentry from '@sentry/nextjs'
// Analytics: PostHog (free up to 1M events/month)
// - User identification
// - Feature flag evaluation
// - Funnel analysis
// - Session recordings
// Logs: Vercel's built-in function logs are enough at the start
// When you outgrow them: Axiom ($25/month, extremely good DX)What to measure from day 1:
- Signup → first meaningful action conversion rate
- Feature usage by cohort (which features do retained users actually use?)
- Churn events — which users left and what did they do before leaving?
- Error rate per route
The Complete Stack Summary
What This Stack Costs at Different Stages
Pre-revenue / MVP:
- Neon: $0 (free tier)
- Clerk: $0 (under 10k MAU)
- Vercel: $0 (hobby) or $20/month (pro)
- Sentry: $0 (free tier)
- PostHog: $0 (free tier)
- Total: $0–20/month
$1k–10k MRR:
- Neon: $19/month
- Clerk: $25/month
- Vercel: $20/month
- Sentry: $26/month
- PostHog: $0 (likely still free tier)
- Total: ~$90/month — 1% of your MRR at $10k
$10k–50k MRR:
- Infrastructure scales here. Vercel may hit $200+/month, consider Railway migration
- All-in: $300–500/month — still under 1% of revenue
What Breaks First (And How to Prepare)
Every stack has a breaking point. Here's where this one cracks:
Vercel cold starts — if you have API routes that run rarely, the serverless cold start (500ms–2s) will be noticeable. Fix: use Edge Runtime for latency-sensitive routes, or move to Railway containers.
Drizzle N+1 queries — Drizzle doesn't automatically batch related queries. Use with for eager loading, add DataLoader patterns for dynamic batching. Add a query logger in development and audit anything above 5 queries per page.
Clerk webhook ordering — Clerk delivers webhooks asynchronously and occasionally out of order. Design your handlers to be idempotent and order-independent.
Stripe's test mode vs live mode — use a separate database for your staging environment (Neon's branch feature) and a separate set of Stripe webhook endpoints. Never test with production data.
The Opinionated Additions Worth Having Day 1
Things that aren't in every tutorial but matter immediately:
// Validate all env vars at startup — fail fast with clear errors
import { z } from 'zod'
const schema = z.object({
DATABASE_URL: z.string().url(),
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string(),
CLERK_SECRET_KEY: z.string(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
SENTRY_DSN: z.string().url().optional(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
})
export const env = schema.parse(process.env)Feature flags from day 1 — even before you need them. PostHog includes feature flags for free. It lets you ship behind a flag, enable for specific users, and roll back without a deploy.
Structured logging — console.log is noise. Add a tiny logger:
export const log = {
info: (msg: string, meta?: object) => console.log(JSON.stringify({ level: 'info', msg, ...meta, ts: Date.now() })),
error: (msg: string, meta?: object) => console.error(JSON.stringify({ level: 'error', msg, ...meta, ts: Date.now() })),
warn: (msg: string, meta?: object) => console.warn(JSON.stringify({ level: 'warn', msg, ...meta, ts: Date.now() })),
}
// Usage
log.info('User signed up', { userId, plan: 'pro' })
log.error('Stripe webhook failed', { eventId, error: err.message })Structured JSON logs are searchable in Vercel, Axiom, and every log aggregator.
The Decision You'll Regret Not Making Early
Multi-tenancy model. If your SaaS will ever have organizations (teams, workspaces, companies), decide now whether each organization gets:
- Shared database, shared schema with a
tenantIdcolumn (Row-Level Security via PostgreSQL policies) - Shared database, separate schemas
- Separate databases per tenant
The first option (RLS with tenantId) is the right default for most SaaS products under 1000 organizations. Implementing it later means touching every query in the codebase. Build it into the schema from the start.
export const projects = pgTable('projects', {
id: text('id').primaryKey().$defaultFn(() => createId()),
orgId: text('org_id').notNull(), // Clerk organization ID
userId: text('user_id').notNull(), // Creator
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
// Every query scoped to orgId — never query without it
export async function getOrgProjects(orgId: string) {
return db.query.projects.findMany({
where: eq(projects.orgId, orgId),
})
}Next in the Series
This article covered the decisions. The next ones go deep on the implementation:
- tRPC + Next.js complete guide — the full setup, context, protected procedures, and client integration
- Clerk authentication guide — webhooks, organizations, metadata, and common pitfalls
- Drizzle ORM complete guide — migrations, relations, transactions, and production patterns
- Stripe + Next.js payments — the full checkout flow, subscriptions, and webhook handling
The next article in the Build a SaaS series: Auth in Production — what Clerk docs don't tell you.