Next.js 14 App Router Guide — Server Components, Streaming, and Caching

A practical guide to the Next.js App Router: Server vs Client Components, Streaming with Suspense, data fetching patterns, caching strategies, and common migration pitfalls.

Introduction

Next.js 13+ introduced the App Router — a complete reimagining of how Next.js applications are structured, built on React Server Components (RSC). It is not a minor update; it is a fundamentally different mental model. This guide cuts through the confusion and explains what actually matters for building production applications.

App Router vs Pages Router

The new app/ directory coexists with the old pages/ directory. You can migrate incrementally. The core differences:

Feature Pages Router App Router
Default componentClient ComponentServer Component
Data fetchinggetServerSideProps, getStaticPropsasync/await in components
LayoutsCustom _app.tsxlayout.tsx files
Loading statesManualloading.tsx + Suspense
Error handling_error.tsxerror.tsx per segment

Server Components vs Client Components

By default, every component in app/ is a Server Component. Server Components run only on the server — they can access databases, filesystem, and secrets directly, but cannot use browser APIs, state, or event handlers.

// app/products/page.tsx — Server Component (default)
// No 'use client' directive = runs on server only

async function ProductsPage() {
  // Direct database access — no useEffect, no API call needed
  const products = await db.query("SELECT * FROM products LIMIT 20")

  return (
    <div>
      <h1>Products</h1>
      {products.map(p => (
        <ProductCard key={p.id} product={p} />
      ))}
    </div>
  )
}
// components/AddToCart.tsx — Client Component
'use client' // This directive marks it as a Client Component

import { useState } from 'react'

export function AddToCart({ productId }: { productId: string }) {
  const [loading, setLoading] = useState(false)

  async function handleClick() {
    setLoading(true)
    await fetch('/api/cart', {
      method: 'POST',
      body: JSON.stringify({ productId })
    })
    setLoading(false)
  }

  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? 'Adding...' : 'Add to Cart'}
    </button>
  )
}

The key rule: Server Components can import and render Client Components, but Client Components cannot import Server Components (only receive them as props/children). Keep 'use client' at the leaves of your component tree.

File Conventions in app/

app/
├── layout.tsx          # Root layout — wraps all pages, never unmounts
├── page.tsx            # Homepage route (/)
├── loading.tsx         # Loading UI for this segment (Suspense wrapper)
├── error.tsx           # Error boundary for this segment
├── not-found.tsx       # 404 for this segment
├── products/
│   ├── layout.tsx      # Products layout
│   ├── page.tsx        # /products
│   └── [id]/
│       ├── page.tsx    # /products/123
│       └── loading.tsx # Loading for individual product
└── api/
    └── cart/
        └── route.ts    # API route: POST /api/cart

Layouts — Persistent UI

// app/layout.tsx — Root layout
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: { template: '%s | My App', default: 'My App' },
  description: 'Product description'
}

export default function RootLayout({
  children
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <nav>...navigation...</nav>
        <main>{children}</main>
        <footer>...footer...</footer>
      </body>
    </html>
  )
}

Streaming with Suspense

Streaming sends HTML to the browser in chunks as it becomes available. Components wrapped in Suspense stream their content independently — the shell renders immediately, slow components stream in when ready.

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { RevenueChart } from './RevenueChart'
import { RecentOrders } from './RecentOrders'

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>

      {/* Streams when revenue data resolves */}
      <Suspense fallback={<div className="skeleton h-72" />}>
        <RevenueChart />
      </Suspense>

      {/* Streams independently — not blocked by chart */}
      <Suspense fallback={<div className="skeleton h-96" />}>
        <RecentOrders />
      </Suspense>
    </div>
  )
}

// RevenueChart.tsx — Server Component with slow query
async function RevenueChart() {
  const data = await fetchRevenueData() // slow DB query
  return <LineChart data={data} />
}

Data Fetching and Caching

The App Router extends the native fetch API with caching controls:

// Static — cached indefinitely, like getStaticProps
const data = await fetch('https://api.example.com/config', {
  cache: 'force-cache'
})

// Dynamic — fresh every request, like getServerSideProps
const data = await fetch('https://api.example.com/live-prices', {
  cache: 'no-store'
})

// ISR — revalidate every hour
const posts = await fetch('https://api.example.com/posts', {
  next: { revalidate: 3600 }
})

// Tag-based on-demand revalidation
const posts = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] }
})
// app/api/revalidate/route.ts — webhook to bust cache
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'

export async function POST(req: NextRequest) {
  const { tag } = await req.json()
  revalidateTag(tag) // bust all fetch() calls with this tag
  return Response.json({ revalidated: true })
}

Server Actions — Mutations Without API Routes

// app/contact/page.tsx
import { revalidatePath } from 'next/cache'

async function submitContact(formData: FormData) {
  'use server'

  const name = formData.get('name') as string
  const email = formData.get('email') as string

  await db.insert('contacts', { name, email })
  revalidatePath('/contacts')
}

export default function ContactPage() {
  return (
    <form action={submitContact}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <button type="submit">Submit</button>
    </form>
  )
}

Route Handlers — API Endpoints

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(req: NextRequest) {
  const users = await db.query("SELECT id, name, email FROM users")
  return NextResponse.json(users)
}

export async function POST(req: NextRequest) {
  const body = await req.json()
  const user = await db.insert('users', body)
  return NextResponse.json(user, { status: 201 })
}

// app/api/users/[id]/route.ts
export async function GET(
  req: NextRequest,
  { params }: { params: { id: string } }
) {
  const user = await db.findById(params.id)
  if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 })
  return NextResponse.json(user)
}

Common Pitfalls

  • Over-using 'use client' — push it to the leaves. A page that needs one interactive button does not need to be a Client Component; only that button does.
  • Forgetting Suspense — async Server Components without Suspense boundaries block the entire page
  • Non-serializable props — you cannot pass functions or class instances from Server to Client Components as props
  • Cache confusion — Next.js has four caching layers; understand each one before debugging slow pages
  • Missing 'use server' in actions — without the directive, the function runs on the client

DevKits Tools for Next.js Development

Build your Next.js applications faster with these DevKits tools:

  • JSON Formatter — validate API Route responses and Server Action payloads
  • UUID Generator — generate IDs for your database records during development

Summary

The App Router's mental model in one sentence: everything is a Server Component by default; add 'use client' only when you need state, effects, or browser APIs. The benefits — zero client JavaScript for data-heavy pages, direct database access, automatic streaming — are real but require understanding the new paradigm first.