Hamza Yerrou
Hamza Yerrou
Software Engineer
Index/Writing/Front-end craft
2026 · 02 · 14 · 11 min read

Three years of Next.js: what I'd start with today

The exact stack I'd reach for if I were starting a new SaaS product on a Monday. Trade-offs, not religion.

I've been building in Next.js since the pages router days. Three years of production work across a SaaS platform, a dozen freelance projects, and two side products. Long enough to have real opinions about what actually matters versus what feels important on day one.

If I were starting a new SaaS product today, here's the stack I'd reach for. Not the flashiest. The one that gets out of the way.

The foundation

Next.js 15 App Router, TypeScript strict, Tailwind with CSS variables, Vercel.

The App Router mental model took me time to trust. Server components by default, "use client" only where you genuinely need interactivity. For a SaaS app that's mostly data display with a few interactive widgets, this maps well to what you're actually building.

TypeScript strict is the thing I'd die on a hill for:

hljs json
{ "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true } }

The extra annotations up front cost maybe 10% more time. The bugs you avoid are worth multiples of that. I have not found a codebase that regretted enabling strict mode.

Server components in practice

The rule I follow: server components for anything that touches a database or API. Client components for anything that needs useState, browser APIs, or event handlers.

hljs tsx
// Server component: renders on the server, ships zero JS to the client async function DashboardPage() { const metrics = await db.query.metrics.findMany({ where: eq(metrics.userId, session.userId), }) return <MetricsGrid data={metrics} /> } // Client component: only for the interactive part 'use client' function MetricsGrid({ data }: { data: Metric[] }) { const [range, setRange] = useState<'7d' | '30d'>('7d') return ( <div> <RangePicker value={range} onChange={setRange} /> <Chart data={filterByRange(data, range)} /> </div> ) }

The mistake I see most often: marking everything as a client component because it's familiar, then wondering why Core Web Vitals are bad. Server components are opt-out, not opt-in. Default to server. Add 'use client' only when the linter tells you you need it.

State management

TanStack Query for server state. useState / useReducer for local UI. Zustand if you genuinely need shared client state.

That's the whole list. I haven't reached for Redux in two years. Most "shared state" problems are "fetch this data once and use it in multiple places" problems, which TanStack Query handles cleanly with its cache.

Testing

Vitest for utilities and hooks. React Testing Library for components with real branching logic. Cypress for critical user flows.

The ratio matters: lots of Vitest tests (fast, cheap), some component tests, a small set of Cypress tests covering login, the core feature, and payment.

hljs yaml
# .github/workflows/ci.yml name: CI on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20' } - run: npm ci - run: npm run lint - run: npm run test - run: npm run build

Set this up on day one. Not week three when you have technical debt and a deadline. Day one. A morning of configuration pays back in weeks.

What I skip now

CSS-in-JS: CSS variables with Tailwind utilities wins on performance and DX. Emotion's runtime overhead shows up in your LCP. I haven't touched it since 2024.

Prisma on day one: I add it when the data model is stable, usually around week three or four. Migrating a schema you're still inventing every day is genuinely painful.

Monorepo tooling before you have multiple packages: Turborepo is excellent and also completely unnecessary for a single product. Start simple.

Storybook on a product team: It creates a second surface to maintain and almost nobody uses it after the first sprint. Build a visual component playground if your design system is genuinely that complex. Otherwise, skip it.

Auth

NextAuth v5 for most things. Clerk or WorkOS if auth is genuinely part of the product: team invitations, fine-grained RBAC, audit logs, SSO.

Don't build auth from scratch. It's not interesting work and the edge cases are genuinely subtle.

The honest part

Three years and I still reach for the same things: TypeScript strict, Tailwind, TanStack Query, Vitest, real CI. None of these are exciting. All of them reduce friction without adding their own.

The stack matters less than most engineers think. What travels across frameworks is discipline: typing everything, testing the things that matter, treating code review as a practice not a formality. The engineer who ships clean Vue.js ships clean Next.js. The engineer who doesn't, doesn't.

← Older
How I quote a freelance project in 48 hours
Newer →
Wallet UX in eight unforgivable mistakes