/blog · next-js
Next.js Patterns I Use on Every Project
After building 11 production apps with Next.js App Router, these are the architectural patterns I reach for every time — from data fetching to auth to error boundaries.
After shipping 11 production Next.js apps, I've converged on a set of patterns that show up in every codebase. These aren't theoretical — they're the result of maintaining real apps with real users and discovering which approaches hold up over time.
Route Group Organization
I use route groups to separate concerns without nesting URL segments:
src/app/
(auth)/ — login, signup, password reset
(public)/ — marketing pages (no auth required)
(app)/ — private dashboard (auth required)
(admin)/ — admin panel (isAdmin required)
api/ — API routes
This keeps middleware auth checks clean and makes the intent of each section immediately obvious to any future collaborator (including future-me).
Centralized Auth Check
Every private page calls the same auth() helper rather than duplicating session checks:
// src/lib/auth.ts (NextAuth v5)
export { auth, signIn, signOut } from '@/auth'
// In any server component or route:
const session = await auth()
if (!session?.user) redirect('/login')
The middleware handles most redirects, but explicit checks inside server components catch edge cases and give you the session object for downstream use.
Prisma Singleton
The standard Next.js hot-reload pattern — prevents connection pool exhaustion in dev:
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
API Route Shape
Every API route returns a consistent JSON shape so the client-side can handle errors uniformly:
// Success
return Response.json({ data: result }, { status: 200 })
// Error
return Response.json({ error: 'Descriptive message' }, { status: 400 })
Client components use a simple fetch wrapper that checks response.ok and extracts data or error without any ceremony.
Zod Schema Colocated with Routes
I define the Zod schema at the top of the API route file, not in a shared types/ directory. The schema is specific to that endpoint's input — sharing it would create artificial coupling between unrelated routes.
const schema = z.object({
title: z.string().min(1).max(200),
url: z.string().url(),
tags: z.array(z.string()).max(10),
})
Shared types (like the Prisma-generated types) come from @prisma/client directly.
Error Boundaries Per Segment
Every major page section that fetches data gets wrapped in an <ErrorBoundary> with a meaningful fallback. The error.tsx convention in Next.js handles route-level errors, but inline boundaries prevent one failing fetch from killing the whole page.
What I Skip
- No Redux or complex state management —
useState+ server components handle 95% of cases - No class components — hooks all the way
- No custom CSS — Tailwind with design tokens via CSS variables
- No
getStaticProps/getServerSideProps— App Router patterns only
These patterns let me move fast on new projects while keeping the codebases consistent enough that switching between them doesn't require relearning the conventions.