Building Resilient Systems with Next.js, Drizzle, and Serverless Postgres

The stack this site runs on
Any stack is easy to defend in a blog post and annoying to live with six months later. So rather than argue for this one in the abstract, here's the one I actually run, and what it cost me.
This site is Next.js App Router, Drizzle ORM, and Postgres hosted on Neon. The database isn't decorative — there's a small API game sitting behind the portfolio, with players, sessions, badges, and a leaderboard. That's the part that needed real persistence, and it's the part that taught me most of what's below.
Server Components moved where my queries live
The App Router's big shift is React Server Components, and the practical effect is simpler than the discourse around it suggests: the component runs on the server, so it can just query the database.
No API route that exists only to be called by my own frontend. No fetch layer wrapping a fetch layer. The page that needs the leaderboard queries for the leaderboard.
Two things follow from that:
- Less client JavaScript. Static content ships as HTML instead of a bundle that rebuilds it.
- Streaming. Wrap a slow query in
<Suspense>and the rest of the page renders immediately instead of everyone waiting on the slowest thing.
The tradeoff is that the boundary between server and client stops being obvious. You find it by putting "use client" in the wrong place and watching the build tell you.
Why Drizzle
I wanted type safety without an ORM that hides SQL from me. Drizzle lands there: the schema is TypeScript, the query results are typed from it, and the query builder reads close enough to SQL that I can still reason about what it'll actually run.
Here's a slice of the real schema behind the game:
export const players = pgTable('players', {
id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
username: varchar('username', { length: 100 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
});
export const challengeCompletions = pgTable('challenge_completions', {
id: serial('id').primaryKey(),
sessionId: integer('session_id').references(() => gameSessions.id).notNull(),
playerId: integer('player_id').references(() => players.id).notNull(),
challengeName: varchar('challenge_name', { length: 100 }).notNull(),
badge: integer('badge').notNull(),
metadata: jsonb('metadata'),
});
That's the whole definition. No decorators, no separate DSL file, no codegen step to keep in sync — drizzle-kit generate reads this and writes the migration.
Neon, and the driver choice that matters
Serverless Postgres solves the thing that makes traditional Postgres painful in serverless: connection limits. Functions spin up by the hundred, each wants a connection, and a normal Postgres instance falls over. Neon pools for you, branches like git, and scales to zero when nobody's playing the game.
But there's a fork here that's worth understanding before you pick:
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
const sql = neon(connectionString);
export const db = drizzle(sql, { schema });
neon-http sends each query as a single HTTP request. It's the fastest option for one-shot reads and it works anywhere, including the edge. What it does not give you is interactive transactions — if you need to hold a transaction open across several dependent queries, you want the WebSocket driver instead. Pick the HTTP one because your queries are independent, not because it appeared first in the docs.
The one line that keeps builds alive
This is the detail I'd have wanted someone to tell me:
const connectionString =
process.env.DATABASE_URL ||
'postgres://placeholder:placeholder@localhost:5432/placeholder';
Next.js evaluates your modules during the build. neon() validates its connection string when you call it. So in any environment where DATABASE_URL isn't set — CI, a fresh clone, a preview build wired up before the secrets are — the module throws at import time and the entire build fails, on a page that may never touch the database.
The placeholder keeps module initialisation happy. Nothing actually connects until a query runs, so a fake string costs nothing at runtime and buys you a build that doesn't depend on secrets being present to compile.
It looks like a hack. It is a hack. It's also the difference between a build that works for the next person who clones the repo and one that doesn't.
What I'd tell someone picking this
The three pieces genuinely fit: Neon handles scale and connections, Drizzle keeps the queries typed and legible, and the App Router puts the data fetch next to the thing that needs it.
What I wouldn't claim is that it's frictionless. The server/client boundary takes a while to feel natural, the HTTP driver's transaction limits will find you eventually, and migrations still need the same care they need anywhere.
It's a good stack. It's not a stack that thinks for you — which, having relied on tools that promised to, I now count as a feature.
