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.
