The stack we converged on after four projects
After building four distinct products with Next.js 14 App Router, Prisma and PostgreSQL, we consolidated a set of patterns we use consistently. These are not dogmas, they are solutions to problems that bit us more than once.
Pattern 1: Prisma client singleton with configurable logs
The most common error with Prisma in Next.js is instantiating multiple clients in development, which exhausts database connections. The solution is a singleton that uses globalThis to persist the instance between Next.js hot-reloads.
Pattern 2: Server Actions with two-layer Zod validation
Server Actions are real HTTP endpoints: anyone can call them directly without going through the client. Our pattern: Zod validation on both client (for fast UX) and server (for real security). The Zod schema is defined once and imported in both places.
Pattern 3: Protected routes with Next.js middleware
The middleware reads the JWT from the cookie, verifies it, and if invalid redirects to login. If valid, it adds headers with user data that Server Components read without touching the database. This centralizes authentication logic in one place.
Pattern 4: Server Components with Suspense for slow data
Instead of showing a global spinner while the entire page loads, we use React Suspense to show the page partially while slow data loads in the background. Next.js streams the HTML, sending static parts first.
Pattern 5: Cache invalidation with revalidateTag
We assign tags to database queries and invalidate them in Server Actions that mutate that data. Example in OmniTrip: when a booking is created, we invalidate the departures-availability tag that affects the public calendar.
What we don't use
tRPC: with Server Actions the configuration overhead is not worth it, they are already natively type-safe in Next.js. Zustand or Redux for global state: with Server Components, most global state lives in the URL or in the database. For remaining UI state, local useState is sufficient.
