engineering//3 min read
A boring stack for a new SaaS, and why boring wins
The best stack for a new product is the one your team can ship and debug at 2 a.m. A practical default for web SaaS, what each piece is for, and when to reconsider.

In short
- Choose technology your team already knows well; novelty spends your attention on the wrong problems.
- Start with a modular monolith and one Postgres database. Split only when a real constraint forces it.
- TypeScript end to end removes a whole class of integration bugs between front end and back end.
- Write down when you would reconsider each choice, so the decision stays honest.
Every early product has a budget of novelty. Spend it on the thing your customers pay for, not on your infrastructure. That is the whole argument for a boring stack: well-documented, widely used tools that fail in known ways and that you can hire for.
A sensible default
This is the shape we reach for when a web SaaS has no constraint pulling it elsewhere. None of it is exotic, which is the point.
Layer | Default | Why | Reconsider when |
|---|---|---|---|
Language | TypeScript | One language and one type system across client and server | Heavy data science or ML work dominates |
Web framework | Next.js (App Router) | Server rendering, routing and APIs in one place | The product is mostly an API with a thin client |
Database | PostgreSQL | Relational integrity, JSON when needed, full-text search, mature tooling | You genuinely need a different data model at scale |
Data access | Drizzle or Prisma | Typed queries and migrations in the repo | Queries get complex enough to want raw SQL everywhere |
Auth | A managed provider or a proven library | Security is not your differentiator | You need enterprise SSO beyond what the tool offers |
Payments | Stripe or a regional equivalent | Billing edge cases are endless | Never, until billing itself is your product |
Hosting | A managed platform | Deploys, previews and TLS without an ops team | Costs or compliance push you to your own cloud account |
Monolith first, with seams
One deployable application and one database is the fastest way to ship and the easiest to debug. The trick is to keep seams inside it: modules organised by domain (billing, accounts, the core feature), each with its own data access and a narrow public interface. If you ever need to pull one out into a service, the seam is already there.
src/
modules/
accounts/ # sign-up, teams, roles
billing/ # plans, subscriptions, invoices
reports/ # the core product
app/ # routes call module services, never the database directlyDesign the data model early
Frameworks are easy to change; data is not. Spend real time on the schema: name things clearly, use foreign keys, and decide early how tenants are separated. A typed schema in the repo keeps the database and the code honest with each other.
import { pgTable, serial, text, integer, timestamp } from "drizzle-orm/pg-core";
export const teams = pgTable("teams", {
id: serial("id").primaryKey(),
name: text("name").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const projects = pgTable("projects", {
id: serial("id").primaryKey(),
teamId: integer("team_id").references(() => teams.id).notNull(),
name: text("name").notNull(),
});Things a boring stack still needs
- Error tracking wired in on the first day, front end and back end.
- Background jobs for anything slow: emails, imports, AI calls. A Postgres-backed queue is enough for a long time.
- Feature flags, even simple ones in the database, so you can ship dark and turn things on per customer.
- Backups you have restored at least once. An untested backup is a hope, not a backup.
Choose the stack you can debug half asleep. Your customers will never ask what framework you used.
When boring is wrong
Real constraints override defaults: hard real-time requirements, heavy offline use, regulated data residency, or a team whose deep expertise lies elsewhere. Write those constraints down. If none of them apply, the boring choice is almost always the fast one.

