Robin.te.Hofstee

Next.js App Router Deep Dive

Robin te Hofstee16 min readViews2
Next.js App Router Deep Dive

A practical deep dive into the Next.js App Router: layouts, dynamic routes, Server and Client Components, data fetching, caching, Server Actions, route handlers, and the pitfalls to avoid.

Introduction

The App Router landed in Next.js 13 and became the default in Next.js 14+. It replaces the old pages/ directory with a set of conventions built on React Server Components. It is powerful, but it also changes how you think about routing, data fetching, and where your code runs.

This deep dive walks through the mental model, every special file, the routing syntax, Server vs Client Components, caching, mutations with Server Actions, and the mistakes that trip people up. Most code here targets Next.js 15/16, where params and searchParams are asynchronous.

Pages Router vs App Router

AspectPages RouterApp Router
Directorypages/app/
ComponentsClient by defaultServer by default
LayoutsManual (_app, _document)Nested layout.tsx
Data fetchinggetServerSideProps / getStaticPropsasync components + fetch
MutationsAPI routes + client fetchServer Actions
Loading UIManualloading.tsx + Suspense
StreamingLimitedFirst-class
Route handlerspages/apiapp/**/route.ts

If you are starting fresh, use the App Router. The Pages Router still works and is supported, but new features ship to the App Router first.

The mental model: folders are routes

In the App Router, a folder defines a URL segment. Inside it, special files define what that segment renders.

app/
├── layout.tsx          # root layout (required)
├── page.tsx            # / 
├── about/
│   └── page.tsx        # /about
├── blog/
│   ├── page.tsx        # /blog
│   └── [slug]/
│       └── page.tsx    # /blog/:slug

Only a folder containing a page.tsx (or route.ts) is publicly routable. Folders without one are just organizational.

Special files at a glance

FilePurpose
layout.tsxShared UI that wraps children and persists across navigation
page.tsxThe unique UI for a route; makes it publicly accessible
template.tsxLike a layout, but remounts on every navigation
loading.tsxInstant loading UI shown while the segment loads
error.tsxError boundary for the segment (must be a Client Component)
global-error.tsxCatches errors in the root layout
not-found.tsxUI for notFound() and unmatched routes
route.tsAPI endpoint (cannot coexist with page.tsx in the same segment)
default.tsxFallback for parallel routes
middleware.tsRuns before a request is completed (project root or src/)

Layouts: shared, nested, and stateful

A layout wraps all children of a segment. Crucially, layouts do not re-render on navigation between their children — state and scroll position are preserved.

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

The root layout is the only place you render <html> and <body>. Nested layouts compose on top of it:

// app/blog/layout.tsx
export default function BlogLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <section className="mx-auto max-w-3xl">
      <nav>Blog navigation</nav>
      {children}
    </section>
  )
}

When you navigate from /blog/a to /blog/b, the root layout and BlogLayout stay mounted; only the page.tsx swaps in.

layout.tsx vs template.tsx

A template.tsx looks identical in signature but creates a new instance on every navigation, so effects re-run and state resets. Use it when you genuinely need a fresh mount (for example, enter animations or per-page analytics).

Routing syntax

Dynamic and catch-all segments

// app/blog/[slug]/page.tsx  ->  /blog/hello-world
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  // ...
}
PatternFolderMatches
Dynamic[slug]/blog/hello
Catch-all[...slug]/shop/a/b/c
Optional catch-all[[...slug]]/shop and /shop/a/b

Route groups

Wrap a folder in parentheses to group routes without adding a URL segment. This is how you share a layout between a subset of routes or create multiple root layouts.

app/
├── (marketing)/
│   ├── layout.tsx
│   ├── about/page.tsx      # /about
│   └── pricing/page.tsx    # /pricing
└── (shop)/
    ├── layout.tsx
    └── cart/page.tsx       # /cart

Parallel and intercepting routes

Parallel routes use @folder slots to render multiple pages in one layout (great for dashboards). Intercepting routes use (.), (..), (...) to render a route in the context of another — the pattern behind those Instagram-style modals that are real pages on refresh.

This is an advanced topic worth its own post; the important part for now is knowing both exist.

Linking and navigation

import Link from 'next/link'
 
export default function Nav() {
  return (
    <nav>
      <Link href="/blog">Blog</Link>
      <Link href="/about" prefetch={false}>About</Link>
    </nav>
  )
}

<Link> prefetches visible routes in the background. In Client Components you also have the navigation hooks:

'use client'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
 
export function Toolbar() {
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const router = useRouter()
 
  return (
    <button onClick={() => router.push(`/search?q=${searchParams.get('q')}`)}>
      Search from {pathname}
    </button>
  )
}

On the server, use the imperative helpers from next/navigation:

import { redirect, notFound } from 'next/navigation'
 
if (!post) notFound()
if (!session) redirect('/login')

Server Components vs Client Components

Everything in app/ is a Server Component by default. Server Components run only on the server, can be async, can read from a database directly, and ship zero JavaScript to the browser. They cannot use state, effects, or browser APIs.

Add 'use client' at the top of a file to opt a component — and everything it imports — into the client bundle.

'use client'
 
import { useState } from 'react'
 
export function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

The composition rule

A Client Component can render Server Components only if they are passed as children or props — not imported directly.

// ✅ Server Component passed down through children
import ClientTabs from './ClientTabs'
import ServerList from './ServerList'
 
export default function Page() {
  return (
    <ClientTabs>
      <ServerList />
    </ClientTabs>
  )
}
// ❌ ServerList imported inside a Client Component becomes a client component
'use client'
import ServerList from './ServerList'

Keep the 'use client' boundary as low in the tree as possible so interactive widgets are islands inside a mostly-server page.

Data fetching

Server Components fetch data by being async:

// app/blog/[slug]/page.tsx
export default async function Post({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const res = await fetch(`https://api.example.com/posts/${slug}`)
  const post = await res.json()
 
  return <article>{post.title}</article>
}

There is no useEffect and no loading spinner — the HTML is generated on the server with data already in it.

Deduplicate with React cache

If several components need the same data, wrap the loader in React's cache so it runs once per request:

import { cache } from 'react'
 
export const getUser = cache(async (id: string) => {
  const res = await fetch(`https://api.example.com/users/${id}`)
  return res.json()
})

Avoid request waterfalls

Fetch independent data in parallel instead of awaiting one after another:

// ✅ Parallel
const [post, comments] = await Promise.all([getPost(slug), getComments(slug)])

Caching and rendering modes

fetch is extended by Next.js. The cache behavior controls whether a route is static or dynamic.

// Static by default (cached indefinitely until revalidated)
const posts = await fetch(url)
 
// Never cache — makes the route dynamic
const fresh = await fetch(url, { cache: 'no-store' })
 
// Revalidate in the background every 60 seconds (ISR)
const posts = await fetch(url, { next: { revalidate: 60 } })
 
// Tag-based revalidation
const products = await fetch(url, { next: { tags: ['products'] } })

You can also configure a whole route segment:

export const dynamic = 'force-dynamic' // or 'force-static'
export const revalidate = 60
export const runtime = 'edge' // or 'nodejs'

The mental model:

  • Static — pre-rendered at build time, reused forever. Fastest.
  • ISR — static but periodically regenerated.
  • Dynamic — rendered per request (cookies, headers, no-store, or dynamic export).

If you read cookies() or headers(), the route automatically becomes dynamic.

Mutations with Server Actions

Server Actions let you run server code from a form or event handler without writing an API route. Mark a function with 'use server'.

// app/posts/actions.ts
'use server'
 
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
 
export async function createPost(formData: FormData) {
  const title = String(formData.get('title'))
 
  await db.post.create({ data: { title } })
 
  revalidatePath('/posts')
  redirect('/posts')
}

Then wire it to a form. Because this is a plain <form action={...}>, it even works without JavaScript:

import { createPost } from './actions'
 
export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <button type="submit">Create</button>
    </form>
  )
}

revalidatePath / revalidateTag refresh cached data after a write, and redirect sends the user somewhere new.

Route handlers

For webhooks and public HTTP endpoints, use a route.ts:

// app/api/posts/[id]/route.ts
import { NextResponse } from 'next/server'
 
export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const { id } = await params
  const post = await getPost(id)
 
  if (!post) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 })
  }
 
  return NextResponse.json(post)
}
 
export async function POST(request: Request) {
  const body = await request.json()
  // ...
  return NextResponse.json({ ok: true }, { status: 201 })
}

Supported exports: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. All handlers are static by default when they do not use the request — add caching exports as needed.

Loading, streaming, and Suspense

Next.js streams UI by default. A loading.tsx in a segment shows instantly while the page's data resolves:

// app/blog/loading.tsx
export default function Loading() {
  return <div className="animate-pulse">Loading posts…</div>
}

For finer control, wrap slow parts in <Suspense> so the shell renders immediately and the slow section streams in:

import { Suspense } from 'react'
 
export default function Page() {
  return (
    <>
      <Header />
      <Suspense fallback={<Skeleton />}>
        <SlowServerComponent />
      </Suspense>
    </>
  )
}

Error handling

error.tsx wraps a segment in an error boundary. It must be a Client Component and receives error and reset:

'use client'
 
export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}

Use notFound() for the "expected error" case — it renders the nearest not-found.tsx and returns a 404. Reserve error.tsx for genuinely unexpected failures.

Metadata and SEO

Static metadata lives in a config export:

import type { Metadata } from 'next'
 
export const metadata: Metadata = {
  title: 'Blog',
  description: 'Articles about web development',
  openGraph: { images: ['/og.png'] },
}

Dynamic pages compute it from params:

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)
 
  return {
    title: post.title,
    description: post.excerpt,
  }
}

Middleware

middleware.ts runs before a request completes — ideal for auth gating, redirects, and rewriting:

// middleware.ts
import { NextResponse, NextRequest } from 'next/server'
 
export function middleware(request: NextRequest) {
  const token = request.cookies.get('session')
 
  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
 
  return NextResponse.next()
}
 
export const config = {
  matcher: ['/dashboard/:path*'],
}

Middleware runs on the Edge runtime by default, so it cannot use Node-only APIs or your database client directly.

Common pitfalls

  • Marking too much as 'use client'. It pushes a whole subtree into the bundle. Keep the boundary small.
  • Forgetting params/searchParams are Promises. In Next 15+ you must await them.
  • Data-fetching waterfalls. Chain awaits and each request waits for the previous one. Use Promise.all.
  • Assuming fetch is uncached. It is cached by default in the App Router; opt out with cache: 'no-store'.
  • Importing a Server Component into a Client Component. It silently becomes a client component.
  • Expecting layouts to re-render. They persist — put per-navigation logic in template.tsx.
  • Mixing page.tsx and route.ts in the same segment. A route can have one or the other, not both.

Cheat sheet

Routing
  [slug]          dynamic segment
  [...slug]       catch-all
  [[...slug]]     optional catch-all
  (group)         route group (no URL segment)
  @slot           parallel route
  (.) (..) (...)  intercepting route
 
Special files
  layout.tsx  page.tsx  template.tsx  loading.tsx
  error.tsx   global-error.tsx  not-found.tsx
  route.ts    default.tsx  middleware.ts
 
Data
  async component          server data fetching
  cache()                  per-request dedupe
  cache: 'no-store'        opt out of caching
  next: { revalidate, tags }
  revalidatePath/Tag()     invalidate after write
 
Server Actions
  'use server' + <form action={fn}>

Conclusion

The App Router is less about new syntax and more about a new default: the server does the heavy lifting, and the client only handles interactivity. Once you internalize Server Components, nested layouts, and the caching rules, you get better performance and simpler data flow than the old getServerSideProps dance.

Start small — build a page with layout.tsx, a loading.tsx, and one Server Action — and expand from there. The rest of the conventions follow the same pattern.

Further Reading


Questions or feedback? Reach out on Twitter/X.

Share this post
Robin te Hofstee

Written by Robin te Hofstee

Technology enthusiast with a deep love for programming. Sharing practical guides on React, Next.js, Linux and self-hosting.