Skip to content
51studio
Tutorials

Postgres + Next.js: a sane starting stack

By Sam Hollis12 min read

Every web project we start has the same first decision: what's the stack?

The honest answer for most projects is "the boring one." Postgres for the database. Next.js for the framework. TypeScript end to end. A handful of choices about how to wire them. The boring stack ships fast, scales fine for the first few years, and doesn't fight you.

This post is what's in that stack and the decisions that matter inside it. It's not a tutorial. It's the rationale for the boring choices, so the boring choices are deliberate instead of accidental.

The components

The defaults we reach for:

  • Next.js (App Router) — React, with server components, server actions, and route-level caching.
  • Postgres — relational database, hosted on Neon or Supabase.
  • Drizzle — TypeScript ORM and migration tool.
  • TypeScript — everywhere, strict mode, no any without justification.
  • Tailwind — utility-first CSS.
  • Vercel — hosting.
  • Resend — transactional email.
  • Sanity — CMS, if the app has content alongside data.
  • Clerk or NextAuth — authentication (see the auth post for the choice).

This stack covers maybe 80% of the projects we ship. The rest are exceptions with reasons (compliance requiring self-hosting, real-time requirements that need specific infra, scale requirements that justify managed Postgres alternatives).

Why Postgres

Postgres is the right answer for almost any project that needs a relational database. The reasons:

  • It's been production-grade for two decades. The edge cases are documented.
  • The feature set is generous: JSON columns, full-text search, materialised views, triggers, generated columns, row-level security. You won't outgrow it.
  • Every managed provider supports it. Neon, Supabase, RDS, Cloud SQL, Crunchy. You're not locked in.
  • The ecosystem around Postgres in TypeScript-land is mature. Drizzle, Prisma, Kysely, raw SQL — all good options.

The cases where Postgres isn't the answer: when you have a clear NoSQL shape (event logs, time-series, document-heavy schemas without joins) and your team has experience with the alternative. Otherwise, defaulting to Postgres saves a year of "we should have picked Postgres."

Why Neon or Supabase

For Postgres hosting we recommend either Neon or Supabase. The reasons we don't manage our own Postgres in production:

  • Backups, failover, point-in-time recovery, patching: these are full-time jobs done badly by part-time DBAs. Pay someone.
  • The cost is reasonable. For most apps, $20-100/month covers a real database with backups and decent connection limits.
  • Both have a free tier good enough for development and small production loads.

Between them:

  • Neon if you want serverless Postgres with branching. Database-per-pull-request is a real feature. Cold-start latency is sub-second now.
  • Supabase if you want Postgres plus auth plus storage plus realtime in one bundle. Useful when you're shipping fast and don't want to compose four services.

We default to Neon for most projects because we already use Clerk or NextAuth for auth, and Sanity for content, and we don't need Supabase's bundle. For projects starting from zero that want one console for everything, Supabase is a fine choice.

Connection management

The thing that bites everyone using Postgres with serverless Next.js: connection exhaustion.

Serverless functions cold-start. Each one wants its own database connection. A traffic spike means a hundred concurrent functions trying to open connections. Postgres has limits. You hit them, the database refuses new connections, the site falls over.

Solutions, in increasing order of how much they help:

  1. Singleton client in your code. Make sure the Postgres client is reused across requests within the same warm function. Trivial in App Router; harder in pages-router serverless without specific patterns.
  2. HTTP-based driver. Neon's serverless driver uses HTTP under the hood, not raw TCP. No connection pool to exhaust on the client side. This is what we use by default.
  3. Connection pooler. Both Neon and Supabase offer a pooler (PgBouncer). Sits between your serverless functions and the database, multiplexes connections. Enable it. It's free.

If you ignore connection management until production, you'll spend a Friday night debugging it. Set it up from day one.

Drizzle, Prisma, or raw SQL

Three reasonable answers. Our default is Drizzle. The trade-offs:

Drizzle — TypeScript ORM. Schema in TypeScript, queries that look like SQL with type inference. Migrations via SQL files or generated. Lightweight. We use it on most new projects.

Prisma — heavier, more abstraction. The generated client is huge. The query API is further from SQL, which is fine for simple queries and limiting for complex ones. Good if your team has used it before. Avoid if you care about cold-start size on serverless.

Raw SQL — the most honest option. Use postgres.js or pg. Write SQL strings, tag them as template literals. No ORM. Painful for complex schemas with many tables. Excellent for projects where the schema is small and queries are weird.

We pick Drizzle because it gives us 90% of the type safety of Prisma at 10% of the bundle weight, and because raw SQL stops scaling around ten tables.

Migrations

Three patterns:

  1. Auto-generated migrations from schema changes. Drizzle and Prisma both do this. Edit the schema file, run a command, get a migration file. Review it. Commit it. Run it.
  2. Hand-written migration files. SQL files numbered sequentially. Run in order. Old-school but bulletproof.
  3. Application-level migrations. Run on app start. Tempting; do not do this in production. You'll hit race conditions when multiple instances start at once.

We use the hybrid: Drizzle generates a draft migration, we review and edit it before commit, and we run migrations as a deploy step (not on app start). For small projects with one engineer, this takes ten minutes total. For larger ones, the review step catches what auto-generation gets wrong.

Server actions vs API routes

Next.js App Router gives you two ways to handle data mutations from the client:

  • Server actions — call a server function directly from a form or client component. Type-safe end to end. New, mostly polished, occasionally weird around error handling.
  • API routes — write a route handler at app/api/.... Older pattern, more boilerplate, completely understood.

We use server actions for in-app mutations (form submissions, edits, deletes) where the caller is our own UI. We use API routes for anything that's a public API or a webhook target.

Server actions are not a public API. If you find yourself thinking "external clients should call this server action," you've made a mistake. Move it to an API route with proper auth.

Type safety end to end

The promise of full-stack TypeScript is that the type of a column in the database flows through to the type of the data in your React component. With this stack, it mostly works:

  1. Drizzle generates types from the schema.
  2. Server actions return those types.
  3. The client component receives them.
  4. Hover over any variable to see its inferred type.

The places this breaks:

  • JSON columns. The type of the JSON contents is up to you. Drizzle has helpers; use them.
  • Migrations that change column types without updating consumers. The types update; the existing code might not. Run typecheck on every PR.
  • Anywhere you as any or // @ts-ignore. Don't.

A type system that's mostly right but occasionally wrong is worse than no type system at all. Resist the urge to escape hatch. Fix the type.

Cost

For a real production app on this stack, a reasonable starting cost:

  • Vercel hosting: $20/month for the Pro plan, more for higher traffic.
  • Neon Postgres: $0-19/month for most projects, $69 for heavier workloads.
  • Resend email: $20/month for the first 50K emails.
  • Sanity (if used): $0 for most marketing-scale content, scales with documents.
  • Clerk or NextAuth: $0 for NextAuth, $25/month for Clerk Pro.

Realistically: $50-150/month for a real production app in its first year. The cost scales with traffic and team size, not with usage of features.

When this stack is wrong

A few cases where we'd reach for something else:

  • Real-time everything. Liveblocks, PartyKit, or a custom WebSocket setup beats Postgres-based polling for true real-time. The hybrid pattern (Postgres for state, WebSocket for live updates) works for most "kinda real-time" needs.
  • Vector search at scale. Postgres with pgvector handles small embedding workloads. For millions of vectors with low-latency search, Pinecone or Qdrant.
  • Geospatial. Postgres with PostGIS is fine. If you need ten-engineer expertise in spatial queries, hire it. We don't pretend to be expert here.
  • Strict compliance. SOC 2 with on-prem requirements pushes you off Vercel/Neon and onto your own infrastructure.

For the other 80%: Postgres + Next.js + Drizzle + TypeScript is the stack that ships.

If you want a web app built on this foundation, see how we work. Real software, designed end to end by the people writing it.

Related articles