deep·tech·intuition
intermediate ·

NextJS Deep Intuition

An experienced engineer's guide to NextJS

1. One-Sentence Essence

Next.js is a server-first React framework that controls where and when your code runs — server or client, build time or request time — so you can make that decision per-component rather than per-application.

2. The Problem It Solved

React, by itself, is a UI library. It renders components in the browser. That’s it. You want routing? Pick a library. Server-side rendering? Wire it up yourself. Static generation? Good luck. Code splitting? Sure, manually. The result was that every serious React project started with two weeks of infrastructure work: setting up Webpack, configuring Babel, choosing a router, building an SSR pipeline, solving hydration bugs, and configuring deployment. Every team solved these problems slightly differently, creating a fragmented ecosystem where sharing knowledge across projects was painful.

Guillermo Rauch, the CEO of Vercel (then ZEIT), launched Next.js in 2016 with a specific insight: building a React app should feel like building a PHP app. You create a file, it becomes a route. You write code, and the framework decides the smartest way to render it. You deploy, and it just works. That was the founding philosophy — zero configuration, convention over ceremony, with server rendering as the default rather than an afterthought.

The key design decision was making the framework responsible for the rendering strategy, not the developer. Instead of you choosing “this is an SSR app” or “this is an SPA,” Next.js let each page make its own choice. A marketing page could be statically generated. A dashboard could be server-rendered. A settings panel could be client-rendered. All in one application, sharing a router, a build system, and a deployment target.

3. The Concepts You Need

Rendering Environments

  • Server Component: A React component that executes only on the server. It can directly access databases, read files, use API keys — anything you’d do in a backend. It sends rendered HTML (and a special payload called the RSC Payload) to the client. It ships zero JavaScript to the browser. In Next.js’s App Router, every component is a Server Component by default.

  • Client Component: A component marked with 'use client' at the top of its file. It runs in the browser (and is also pre-rendered on the server during SSR for the initial HTML). Client Components can use useState, useEffect, event handlers, browser APIs like localStorage — anything interactive. The 'use client' directive is a boundary declaration: it tells the framework “from this file downward, everything runs on the client.”

  • RSC Payload (React Server Component Payload): The serialized output of Server Components. It’s not HTML — it’s a special format React uses to merge server-rendered content with the client-side component tree. When you navigate client-side, Next.js fetches the RSC Payload instead of full HTML, enabling partial updates without full page reloads.

  • Hydration: The process of making server-rendered static HTML interactive. The browser receives HTML (fast, visible), then loads JavaScript that attaches event listeners and state to that HTML (interactive). If the server-rendered HTML doesn’t match what the client-side React produces, you get a hydration error — one of the most common Next.js bugs.

Rendering Strategies

  • Static Rendering (SSG — Static Site Generation): Pages are rendered at build time. The HTML is generated once and served from a CDN. Fastest possible response. Used for content that doesn’t change per-request.

  • Dynamic Rendering (SSR — Server-Side Rendering): Pages are rendered on the server at request time. Every request gets a fresh render. Used when the content depends on request-specific data like cookies, headers, or search parameters.

  • Incremental Static Regeneration (ISR): A hybrid. Pages are statically generated but can be revalidated after a time interval. Stale-while-revalidate: serve the cached version immediately, regenerate in the background. The next visitor gets the fresh version.

  • Streaming: Rather than waiting for the entire page to render, the server sends HTML in chunks as each part becomes ready. Combined with React’s <Suspense>, this means the user sees content progressively — the shell loads fast, and data-heavy sections fill in as they resolve.

Routing

  • App Router: The current routing system (introduced in Next.js 13, stable since 13.4). File-system based: folders define URL segments. Uses React Server Components by default, supports layouts, streaming, and Server Actions. This is what you should use for new projects.

  • Pages Router: The legacy routing system. Each file in pages/ is a route. Uses getServerSideProps, getStaticProps, and getInitialProps for data fetching. Still supported, still works, but new features ship exclusively for the App Router.

  • Layout: A UI component that wraps child routes and persists across navigation. Defined in layout.tsx. Layouts don’t re-render when you navigate between sibling routes — only the changed segment re-renders. This is called partial rendering and it’s one of the App Router’s biggest performance wins.

  • Route Segment: Each folder in the app/ directory is a segment. app/dashboard/settings/page.tsx has three segments: root, dashboard, and settings. Each can have its own layout.tsx, loading.tsx, error.tsx, and page.tsx.

Data and Mutations

  • Server Action: An async function marked with 'use server' that runs on the server but can be called directly from Client Components — typically from form submissions. They replace the need for separate API routes for mutations. Think of them as RPC calls that Next.js handles transparently.

  • Route Handler: The App Router equivalent of API routes. Defined in route.ts files. Support standard HTTP methods (GET, POST, PUT, DELETE). Used when you need a proper API endpoint (webhooks, third-party callbacks, etc.) rather than a Server Action.

  • Middleware: A function that runs before routing, at the edge. It can redirect, rewrite, modify headers, or set cookies. There’s exactly one middleware.ts file per project, placed at the root. Middleware runs on every request, so keep it fast and lightweight.

Caching Layers

  • Full Route Cache: Statically rendered routes are cached as HTML and RSC Payload at build time. Served directly without re-rendering.

  • Data Cache: Individual fetch results can be cached independently of the route, with their own revalidation timers and tags.

  • Router Cache: The client-side cache of previously visited routes. When you navigate back to a page, the cached version shows instantly while a fresh version may be fetched in the background.

  • use cache / cacheLife / cacheTag: The newer Cache Components model (Next.js 15+, enabled via config). use cache marks a function or component as cacheable. cacheLife sets how long. cacheTag attaches a tag for on-demand invalidation via revalidateTag.

4. The Distilled Introduction

Setup

npx create-next-app@latest my-app
cd my-app
npm run dev

You’ll get an app/ directory (App Router), TypeScript configured, Tailwind CSS optional, and ESLint set up. The dev server starts on localhost:3000 with Turbopack as the default bundler — it’s significantly faster than Webpack for development, especially in large projects.

Your First Route

Create app/page.tsx:

export default function Home() {
  return <h1>Hello, world</h1>
}

That’s a Server Component. It runs on the server, generates HTML, and ships zero JavaScript for this component. Visit / and you’ll see it.

Adding Routes

Create app/about/page.tsx and you’ve got /about. Create app/blog/[slug]/page.tsx and you’ve got dynamic routes like /blog/my-first-post. Access the parameter:

export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  return <h1>Post: {slug}</h1>
}

Note: in Next.js 15+, params is a Promise. You await it. This is a breaking change from 14 that catches people.

Layouts

Create app/layout.tsx — this wraps every page:

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

Nested layouts work the same way. app/dashboard/layout.tsx wraps everything under /dashboard/*. Critically, layouts persist across navigation. If you navigate from /dashboard/settings to /dashboard/analytics, the dashboard layout doesn’t re-render. Only the page content swaps. This is how you build shells with sidebars, headers, and tabs that feel instant.

Data Fetching

In the App Router, data fetching is straightforward — you just use async/await in Server Components:

export default async function UsersPage() {
  const users = await db.query('SELECT * FROM users')
  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  )
}

No getServerSideProps. No useEffect. No loading state management. The component is async, it fetches data on the server, and renders. If you wrap it in <Suspense>, the page will stream — showing a fallback while this component resolves.

For fetch specifically, Next.js extends it with caching options:

// Cached indefinitely (static)
const data = await fetch('https://api.example.com/products')

// Revalidate every hour
const data = await fetch('https://api.example.com/products', {
  next: { revalidate: 3600 }
})

// Never cache (always dynamic)
const data = await fetch('https://api.example.com/user', {
  cache: 'no-store'
})

Client Interactivity

When you need useState, useEffect, or event handlers, create a Client Component:

'use client'

import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>
}

Then compose it into a Server Component:

import Counter from './counter'

export default async function Page() {
  const stats = await getStats() // runs on server
  return (
    <div>
      <h1>Dashboard</h1>
      <p>Total users: {stats.totalUsers}</p>
      <Counter /> {/* interactive island */}
    </div>
  )
}

The page itself is a Server Component (zero JS). Only <Counter> ships JavaScript. This is the composition model — Server Components for data and structure, Client Components for interactivity. We’ll see in the Mental Model section why this boundary matters so much.

Server Actions

Need to mutate data? Define a Server Action:

// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  await db.insert('posts', { title })
  revalidatePath('/posts')
}

Use it in a form:

import { createPost } from './actions'

export default function NewPostForm() {
  return (
    <form action={createPost}>
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  )
}

This form works without JavaScript. If JS hasn’t loaded yet (or is disabled), the form still submits. Progressive enhancement, built in. The action runs on the server, mutates your database, and revalidatePath clears the cache so the posts page shows the new data.

Middleware

Create middleware.ts at your project root:

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  if (!request.cookies.get('session')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

export const config = {
  matcher: '/dashboard/:path*',
}

Middleware runs at the edge, before any route is handled. Use it for auth redirects, A/B testing, geolocation-based rewrites, or header manipulation. Don’t use it for heavy computation or database access — it’s meant to be fast.

Static Export

If you don’t need a server at all, add to next.config.ts:

const nextConfig = { output: 'export' }

This produces a static HTML export — every route becomes an HTML file you can host on any CDN. No Node.js server required. You lose SSR, ISR, middleware, and Server Actions, but for purely static sites, it’s the simplest deployment possible.

Building for Production

npm run build  # Creates optimized production build
npm start      # Starts the production server

next build does everything: compiles, optimizes, splits code, generates static pages, and creates the .next directory. next start runs the Node.js server. For deployment, use output: 'standalone' in your config to get a self-contained deployment folder with all dependencies included — ideal for Docker containers.

5. The Mental Model

Core Idea 1: The Server/Client Boundary Is a Physical Line in Your Code

Every file in your codebase runs in exactly one of two places: the server or the client. Server Components stay on the server entirely — their code never reaches the browser. Client Components run on both (server for initial pre-render, client for interactivity).

The boundary is the 'use client' directive. When Next.js encounters this directive, it creates a split point. Everything above it (imported by it) stays on the server. The file and everything it imports become the client bundle.

What this predicts:

  • You can’t import a Server Component into a Client Component (but you can pass Server Components as children props to Client Components — because they’re already rendered on the server and passed as RSC Payload).
  • Secrets and API keys in Server Components are never exposed to the browser — they literally don’t exist in the client bundle.
  • Putting 'use client' on your root layout makes your entire app a Client Component — you’ve just built an SPA. Push the boundary as deep as possible.
  • If you use a third-party library that calls useState internally, you must wrap it in a 'use client' file — even if you don’t write useState yourself.

Core Idea 2: Next.js Decides Your Rendering Strategy Based on What You Do

You don’t tell Next.js “this is an SSR page” or “this is a static page.” Next.js infers it from your code. If your component doesn’t access anything request-specific, it’s statically rendered at build time. The moment you call cookies(), headers(), or read searchParams, the entire route becomes dynamically rendered.

What this predicts:

  • Adding a single cookies() call to a layout makes every page under that layout dynamic. That layout is shared — its rendering mode propagates.
  • fetch results are cached by default. If all your data comes from cached fetches, the route is static.
  • Setting export const dynamic = 'force-dynamic' on a page is the override — it forces dynamic rendering regardless.
  • In development mode, everything is rendered dynamically on every request. Caching only kicks in during next build and next start. This is the #1 reason people think caching isn’t working — they’re testing in dev.

Core Idea 3: Caching Is the Default, Not the Exception

Next.js caches aggressively by default. Fetch responses are cached. Rendered routes are cached. Client-side navigations are cached. The philosophy is: compute once, serve many times.

What this predicts:

  • After deploying a change, users may see stale content until the cache is invalidated. You need revalidateTag or revalidatePath to bust caches after mutations.
  • revalidate: 60 doesn’t mean “refresh every 60 seconds.” It means “after 60 seconds, the next request gets the stale version but triggers a background regeneration. The request after that gets the fresh version.” This is stale-while-revalidate semantics.
  • Multiple fetch calls in a route with different revalidate values? The lowest one determines the route’s ISR interval.
  • The client-side Router Cache means navigating back to a page doesn’t always trigger a server request. router.refresh() explicitly clears it.

Core Idea 4: The File System Is the API

Folder structure isn’t just organization in Next.js — it’s configuration. Each folder in app/ is a route segment. Special files (page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx, route.ts) have specific meanings. This is convention as API.

What this predicts:

  • Renaming app/blog/page.tsx to app/articles/page.tsx changes your URL from /blog to /articles. The file system is your router config.
  • loading.tsx automatically wraps the segment in <Suspense> with the loading component as the fallback. You don’t write <Suspense> yourself — the file creates it.
  • error.tsx creates an error boundary. If the page component throws, the error UI renders instead. It’s a file, not a try/catch.
  • Route groups with (parentheses) create organizational folders that don’t affect the URL. app/(marketing)/about/page.tsx still maps to /about.
  • Parallel routes with @slot folders let you render multiple independent pages in the same layout simultaneously — useful for dashboards and modals.

6. The Architecture in Plain English

When a user requests a Next.js page, here’s what actually happens:

First visit (full page load):

  1. The request hits Middleware (if it exists). Middleware can redirect, rewrite the URL, or modify headers. It runs at the edge.
  2. Next.js checks if this route has a cached static version. If yes, serve it. Done.
  3. If not (or if it’s dynamic), the server starts rendering. It walks the component tree top-down: root layout → nested layouts → page.
  4. Server Components execute. They can call databases, APIs, read files. Their output is the RSC Payload — a serialized tree of rendered HTML and metadata.
  5. Client Component placeholders are inserted into the RSC Payload. They’re not rendered yet — just markers for where interactive components go.
  6. Next.js combines the RSC Payload with Client Component instructions to generate full HTML.
  7. The HTML is sent to the browser — the user sees content immediately.
  8. The browser downloads the JavaScript bundles for Client Components.
  9. React “hydrates” the Client Components — attaching event listeners to the existing HTML. Server Components are not hydrated (they have no JavaScript).
  10. The page is now fully interactive.

Subsequent navigation (client-side):

  1. The user clicks a <Link>. Next.js intercepts the navigation.
  2. It checks the client-side Router Cache. If the destination is cached, render it instantly.
  3. If not, it makes a request to the server for just the RSC Payload of the new route (not full HTML).
  4. The server renders only the segments that changed (partial rendering). Shared layouts are not re-rendered.
  5. The RSC Payload arrives. React updates only the changed parts of the DOM.

Where state lives:

  • Static renders are stored in the Full Route Cache (HTML + RSC Payload on the server/CDN).
  • Fetch results are stored in the Data Cache (server-side, keyed by URL and options).
  • Previously visited routes are stored in the Router Cache (client-side, in memory).
  • Application state (useState, context) lives in Client Components and persists during client-side navigation within a layout (because layouts don’t re-mount).

Turbopack is the Rust-based bundler that replaced Webpack for development in Next.js 16+. It compiles routes on-demand (only when you visit them), uses persistent file system caching, and is dramatically faster for large projects. As of 16.2, it’s the default for both next dev and increasingly for next build.

7. The Things That Bite You

1. “It works in dev but not in production” — the caching mismatch

In development, every request is dynamically rendered. No caching. You see fresh data every time. You deploy, and suddenly pages show stale data. This happens because next build evaluates your route and decides it’s static. Your fetch calls were cached by default. You never noticed because dev doesn’t cache.

Why (Mental Model 2): Next.js infers rendering strategy from your code. If it can statically render, it will. Fix: Be explicit. Use cache: 'no-store' for data that must be fresh, or set revalidate intervals. Always test with next build && next start before deploying.

2. Hydration errors from dynamic content

You render new Date().toLocaleString() in a component. The server renders “4:23 PM.” The client renders “4:23 PM” one second later — different string. Hydration error.

Why (Mental Model 1): The server and client are different environments running at different times. Any non-deterministic value (time, Math.random(), window.innerWidth) will mismatch. Fix: Compute dynamic values in a useEffect after hydration, or use a Client Component that renders a stable fallback initially and updates after mount.

3. The 'use client' infection

You add 'use client' to a component. It imports a utility. That utility imports another. Suddenly half your app is Client Components, and your bundle is massive.

Why (Mental Model 1): 'use client' defines a boundary. Everything imported by that file is client code. Fix: Push 'use client' to the leaf components — the smallest interactive units. Keep data fetching and layout in Server Components. Pass data down to Client Components via props.

4. Server Actions aren’t just for forms

People treat Server Actions as “form handlers.” But they’re general-purpose server-side functions callable from any Client Component. You can call them from onClick, from useEffect, from anywhere. However, they’re called via POST requests under the hood, so they should be mutations, not reads. For reads, use Server Components.

5. Middleware runs on every request — even static assets

Unless you configure the matcher, Middleware intercepts requests for images, CSS, and JavaScript too. Your auth check runs for _next/static/chunk-abc123.js. This is wasteful at best, broken at worst.

Fix: Always configure a matcher that excludes _next/static, _next/image, favicon.ico, and other static assets.

6. The searchParams gotcha

Reading searchParams in a Server Component makes the entire route dynamic. If you have a static blog post page and add a ?ref=twitter tracking parameter, your previously static page is now rendered on every request.

Why (Mental Model 2): searchParams are request-specific data. Next.js can’t know their values at build time. Fix: If you need search params only on the client, read them in a Client Component instead. Or accept the dynamic rendering if the data genuinely changes.

7. Error messages vanish in production

Server Action errors in development show full stack traces. In production, Next.js strips error details to avoid leaking sensitive information. Your users see a generic error, and your logs might be unhelpful.

Fix: Use a structured error handling pattern — return { error: 'message' } from Server Actions instead of throwing. Or integrate Sentry/DataDog for production error tracking.

8. Cache inconsistency across multiple instances

If you self-host with multiple server instances (e.g., Kubernetes pods), each instance has its own local file cache. Instance A regenerates a page; instances B and C serve stale content.

Why (Mental Model 3): The default cache is local filesystem. It wasn’t designed for distributed deployment. Fix: Use a shared cache handler (Redis is the standard choice). Next.js supports custom cacheHandler configuration for exactly this scenario.

8. The Judgment Calls

1. App Router vs. Pages Router for an existing project

Situation: You have a Pages Router app. Should you migrate?

If you’re starting a new project: Always use App Router. Every new feature ships there. Pages Router is in maintenance mode.

If you have a working Pages Router app: Don’t rewrite it. Migrate incrementally. Next.js supports running both routers simultaneously. Move new routes to app/, keep existing ones in pages/. You can share components between them. The signal to migrate a specific route: when you need layouts, streaming, or Server Components for that route.

2. Server Component vs. Client Component

The default should be Server Component. Switch to Client Component only when you need interactivity (useState, useEffect, event handlers) or browser APIs (localStorage, window dimensions, geolocation).

The signal: If you’re writing 'use client' and the component doesn’t have any hooks or event handlers, you probably don’t need it. If you’re importing a library that internally uses hooks, you do.

3. Server Action vs. Route Handler

Use Server Actions for mutations triggered by user interaction — form submissions, button clicks, updating preferences. They’re simpler, they integrate with form validation, and they work with progressive enhancement.

Use Route Handlers when you need a proper HTTP endpoint — webhooks from third-party services, public APIs consumed by mobile apps, endpoints that need specific HTTP methods or headers.

4. When to cache vs. when to go dynamic

Cache (static/ISR): Content that’s the same for all users. Blog posts, product pages, marketing pages, documentation. Use ISR with revalidate so it stays fresh without rebuilding.

Dynamic (SSR): Content that depends on the current user. Dashboards, personalized feeds, authenticated pages. Use cache: 'no-store' on fetches.

The trap: People make entire pages dynamic because one small part needs user data. Instead, make the page static and use a Client Component for the personalized bit. A static shell with a client-rendered user menu is far faster than a fully dynamic page.

5. Deploying on Vercel vs. self-hosting

Vercel is the path of least resistance. Zero configuration. ISR, edge functions, image optimization, and analytics just work. The free tier is generous. Cost becomes a concern at scale — usage-based pricing can surprise you.

Self-hosting gives you control and predictable costs. Use output: 'standalone' and Docker. But you take on the complexity of cache invalidation across instances, image optimization, CDN configuration, and middleware edge deployment. For single-instance deployments, it’s straightforward. For horizontal scaling, you need Redis for cache and careful CDN configuration.

The signal: If your team doesn’t have dedicated DevOps, start with Vercel. If you have strict compliance requirements, cost predictability needs, or existing infrastructure, self-host.

6. When to use ISR vs. on-demand revalidation

Time-based ISR (revalidate: 3600): For content that changes on a predictable schedule. Blog posts, product catalogs, documentation. Set the interval to the maximum staleness you can tolerate.

On-demand revalidation (revalidateTag/revalidatePath): For content that changes unpredictably but needs to be fresh immediately. CMS webhooks, admin panel updates, user-generated content after approval. Wire a webhook to call revalidateTag('posts') when content changes.

The signal: If you’re setting revalidate: 1 to simulate “always fresh,” you should be using cache: 'no-store' (dynamic rendering) or on-demand revalidation instead. One-second ISR gives you all the overhead of caching with none of the benefits.

7. fetch vs. ORM/direct database access

fetch with Next.js caching makes sense when your data comes from external APIs. The built-in cache deduplication and revalidation work seamlessly.

Direct database access (Prisma, Drizzle, raw SQL) is simpler and faster when your database is local or on the same network. But you lose Next.js’s built-in fetch cache. Use unstable_cache or the newer use cache directive to cache the results of direct database queries.

8. Streaming everything vs. blocking on data

Stream (use <Suspense>) when your page has slow data dependencies and you want users to see the shell immediately. Dashboards with multiple data panels, e-commerce pages where product info loads fast but reviews are slow.

Block when the page makes no sense without the data. A single product detail page where the title, image, and price are all from the same query — there’s nothing useful to show as a skeleton. Just render it server-side and send it complete.

9. Middleware vs. layout-level auth

Middleware is best for simple redirect-based auth: “if no session cookie, redirect to /login.” It’s fast, runs at the edge, and catches unauthenticated requests before any rendering happens.

Layout-level auth is better when you need to fetch user data and make decisions based on roles, permissions, or subscription status. You need database access for that, which middleware (edge runtime) may not support with your ORM.

Critical warning: Never use middleware as your only auth layer. The CVE-2025-29927 vulnerability showed that middleware can be bypassed. Always verify authentication inside Server Actions and data access functions too.

9. The Commands/APIs That Actually Matter

Project lifecycle

npx create-next-app@latest       # Scaffold a new project
npm run dev                       # Start dev server (Turbopack, HMR)
npm run build                     # Production build
npm start                         # Production server
npx next lint                     # Run ESLint with Next.js rules
npx next upgrade                  # Upgrade Next.js (16.1+)

File conventions (app/ directory)

page.tsx        → The UI for a route (makes it publicly accessible)
layout.tsx      → Shared UI that wraps children and persists across nav
loading.tsx     → Loading UI (auto-wrapped in Suspense)
error.tsx       → Error boundary UI
not-found.tsx   → 404 UI
route.ts        → API endpoint (GET, POST, PUT, DELETE handlers)
template.tsx    → Like layout but re-mounts on every navigation
default.tsx     → Fallback for parallel routes

Data fetching patterns

// Server Component — direct data access
const data = await db.query('SELECT ...')

// Cached fetch with revalidation
const res = await fetch(url, { next: { revalidate: 3600, tags: ['products'] } })

// Uncached fetch (always fresh)
const res = await fetch(url, { cache: 'no-store' })

// Force entire route to be dynamic
export const dynamic = 'force-dynamic'

// Set revalidation interval for the whole route
export const revalidate = 60

Cache invalidation

import { revalidatePath, revalidateTag } from 'next/cache'

revalidatePath('/posts')           // Invalidate a specific path
revalidateTag('products', 'max')   // Invalidate by tag (stale-while-revalidate)
import Link from 'next/link'
import { useRouter } from 'next/navigation'  // App Router
import { redirect } from 'next/navigation'    // Server-side redirect

// Client navigation
<Link href="/about">About</Link>

// Programmatic navigation (Client Component)
const router = useRouter()
router.push('/dashboard')
router.refresh()  // Re-fetch server data, clear Router Cache

// Server-side redirect (in Server Component or Server Action)
redirect('/login')

Image optimization

import Image from 'next/image'

<Image
  src="/hero.jpg"
  alt="Hero"
  width={800}
  height={600}
  priority        // Skip lazy loading for above-the-fold images
/>

Always use next/image instead of <img>. It automatically serves WebP/AVIF, resizes for the device, and lazy-loads by default.

Metadata and SEO

// Static metadata
export const metadata = {
  title: 'My App',
  description: 'Description here',
}

// Dynamic metadata
export async function generateMetadata({ params }) {
  const post = await getPost(params.slug)
  return { title: post.title }
}

10. How It Breaks

Symptom: Page shows stale data after mutation

Root cause: Cache not invalidated. You mutated data in a Server Action but didn’t call revalidatePath or revalidateTag.

Diagnosis: Check your Server Action. Does it invalidate the relevant cache after the mutation? Check the route — is it statically rendered when it should be dynamic?

Fix: Add revalidatePath('/your-route') or revalidateTag('your-tag') after every mutation. If using ISR, remember it’s stale-while-revalidate — the first request after invalidation still sees stale data.

Symptom: Hydration mismatch warnings

Root cause: Server and client rendered different HTML. Usually caused by Date(), Math.random(), window checks, or browser extensions modifying the DOM.

Diagnosis: The error message in dev tools tells you exactly what text or element mismatched. Find the non-deterministic value.

Fix: Move dynamic values to useEffect. Use suppressHydrationWarning only for unavoidable cases like timestamps. Check for invalid HTML nesting (<div> inside <p>, <a> inside <a>).

Symptom: “Module not found” or unexpected behavior with third-party library

Root cause: The library uses client-only APIs but is being imported in a Server Component.

Diagnosis: The error will mention window, document, localStorage, or similar browser globals.

Fix: Import the library in a Client Component (add 'use client'). Or use next/dynamic with { ssr: false } to skip server rendering entirely for that component.

Symptom: Entire app is slow / large JavaScript bundle

Root cause: 'use client' placed too high in the component tree, making entire sections Client Components.

Diagnosis: Run npx next build and examine the output. Large client-side bundle sizes indicate too much code crossing the server/client boundary.

Fix: Audit your 'use client' directives. Push them as deep as possible. Extract interactive bits into small Client Components and keep the wrapper as a Server Component.

Symptom: Middleware not working as expected

Root cause: Matcher configuration is missing or incorrect. Middleware runs on every request by default — including static assets.

Diagnosis: Add console.log to middleware (it logs server-side). Check if it’s running on paths you don’t expect.

Fix: Always configure the matcher to exclude /_next/static, /_next/image, and other asset paths.

General debugging workflow

  1. Check the terminal/server logs. Server Components and Server Actions log on the server, not the browser console.
  2. Run next build && next start. Many issues only appear in production mode because of caching.
  3. Check the Network tab. Look at the x-nextjs-cache response header: HIT (from cache), STALE (serving stale, regenerating), MISS (fresh render).
  4. Enable fetch logging in next.config.ts: logging: { fetches: { fullUrl: true } }.
  5. Check the .next directory. Static renders are in .next/server/app/. If a page HTML file exists there, it was statically rendered.

11. The Taste Test

Beginner: 'use client' at the top of every file

They came from the old React world where everything is client-rendered. Every component starts with 'use client' because that’s what “makes it work.” The app is functionally an SPA with extra steps.

Experienced: 'use client' only on leaf interactive components

The page.tsx is a Server Component. Data fetching happens there. A small <LikeButton> or <SearchBar> is the Client Component. The boundary is deliberate and narrow.

Beginner: useEffect for data fetching

'use client'
const [data, setData] = useState(null)
useEffect(() => { fetch('/api/data').then(r => r.json()).then(setData) }, [])

This is a waterfall: page loads → JavaScript loads → fetch fires → data arrives → render. Four network round trips.

Experienced: Server Component with async data

export default async function Page() {
  const data = await getData() // one server-side call, zero client JS
  return <DataTable data={data} />
}

One network trip. Zero client JavaScript for the data. Streamed to the browser progressively.

Beginner: One giant API route that does everything

// app/api/data/route.ts — handles all data operations
export async function POST(req) {
  const { action, ...data } = await req.json()
  if (action === 'create') { /* ... */ }
  if (action === 'update') { /* ... */ }
  // 200 lines of if/else
}

Experienced: Server Actions for mutations, Server Components for reads

// No API route needed. Server Action for the write.
'use server'
export async function createItem(formData: FormData) { /* ... */ }

// Server Component for the read.
export default async function Page() {
  const items = await getItems()
  return <ItemList items={items} createItem={createItem} />
}

Red flags in code review

  • export const dynamic = 'force-dynamic' on a page that could be static. They’re using it as a “fix caching issues” hammer.
  • suppressHydrationWarning everywhere. They’re hiding bugs, not fixing them.
  • fetch with no cache or revalidate option, and the developer is surprised by stale data.
  • No loading.tsx or <Suspense> anywhere — users stare at a blank screen while data loads.
  • Server Actions without authentication checks inside them. Relying solely on middleware for auth.
  • revalidate: 1 on routes that should either be truly dynamic or use on-demand revalidation.

12. Where to Go Deeper

Official Next.js Documentation (https://nextjs.org/docs) — The docs are genuinely good, especially the App Router sections. Read the “Getting Started” and “Guides” sections end to end. The caching guide is the most important page you’ll read.

“Understanding React Server Components” (Vercel blog) — The canonical explanation of RSCs and how Next.js implements them. Read this to understand the theory behind the server/client split.

Next.js GitHub Discussions — The real production issues surface here. Search for “caching,” “hydration,” or “self-hosting” to see what actually breaks in the wild. Tim Neutkens and the Vercel team respond directly.

“The Complete Guide to Self-Hosting Next.js at Scale” by @dlhck — If you’re not deploying on Vercel, this is the guide. Covers Docker, reverse proxies, Redis cache handlers, CDN configuration, and multi-instance coordination. Born from real enterprise deployment pain.

Kent C. Dodds: “Why I Won’t Use Next.js” (epicweb.dev) — The best critique of Next.js from a respected React developer. Read it to understand the legitimate tradeoffs: Vercel coupling, complexity, and where Remix’s simpler model wins.

Next.js Learn Course (https://nextjs.org/learn) — Official interactive tutorial. Good for hands-on practice if you learn by building. Works through a real project step by step.

Build a project without tutorials. Take an existing app idea — a blog with comments, a dashboard with real-time data, an e-commerce store — and build it with the App Router. You’ll hit every gotcha in this document within the first week. That’s the point.


The ideas are mine. The writing is AI assisted