Next.js App Router — The Complete Guide for 2026

Sanjeev SharmaSanjeev Sharma
7 min read

Advertisement

Introduction

Why This Matters

The Next.js App Router, stable since Next.js 13.4, is now the default and recommended architecture for every new Next.js project. It fundamentally changes how pages are structured, how data is fetched, and how layouts are composed. If you are still using getServerSideProps and the pages/ directory, this guide will get you up to speed on what changed and why.

App Router vs Pages Router

FeaturePages RouterApp Router
Default component typeClient ComponentServer Component
Data fetchinggetServerSideProps, getStaticPropsasync/await directly in components
Nested layoutsManualBuilt-in via layout.tsx
StreamingNot supportedFull streaming with Suspense
Loading statesManualBuilt-in loading.tsx
Error boundaries_error.jsBuilt-in error.tsx
API routespages/api/app/api/.../route.ts

File Structure and Conventions

The App Router uses the app/ directory. Every folder represents a URL segment. Special files control the UI for each segment:

app/
├── layout.tsx          ← Root layout (required, wraps all pages)
├── page.tsx            ← Home page /
├── loading.tsx         ← Loading UI for /
├── error.tsx           ← Error UI for /
├── not-found.tsx       ← 404 page
├── blog/
│   ├── page.tsx        ← /blog
│   └── [slug]/
│       ├── page.tsx    ← /blog/[slug]
│       └── loading.tsx ← Loading for /blog/[slug]
├── dashboard/
│   ├── layout.tsx      ← Nested layout (persists during navigation)
│   ├── page.tsx        ← /dashboard
│   └── settings/
│       └── page.tsx    ← /dashboard/settings
└── api/
    └── users/
        └── route.ts    ← API Route Handler

Root Layout

Every App Router project requires a root layout.tsx. It renders the html and body tags and wraps every page:

import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
 
const inter = Inter({ subsets: ['latin'] })
 
export const metadata: Metadata = {
  title: { template: '%s | My App', default: 'My App' },
  description: 'Built with the Next.js App Router',
}
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <header>Navigation</header>
        <main>{children}</main>
        <footer>Footer</footer>
      </body>
    </html>
  )
}

Data Fetching — No More getServerSideProps

In the App Router, Server Components are async by default. You fetch data directly with await — no wrappers needed:

// app/products/page.tsx
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    cache: 'no-store',          // SSR — always fresh
    // next: { revalidate: 60 } // ISR — revalidate every 60s
    // cache: 'force-cache'     // SSG — cache indefinitely
  })
  if (!res.ok) throw new Error('Failed to fetch products')
  return res.json()
}
 
export default async function ProductsPage() {
  const products = await getProducts()
 
  return (
    <ul>
      {products.map((product: { id: string; name: string }) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  )
}

Dynamic Routes and generateStaticParams

// app/blog/[slug]/page.tsx
interface Props {
  params: Promise<{ slug: string }>
}
 
export default async function BlogPost({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)
 
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.html }} />
    </article>
  )
}
 
// Pre-generate static pages at build time
export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map((post) => ({ slug: post.slug }))
}
 
// Generate per-page metadata dynamically
export async function generateMetadata({ params }: Props) {
  const { slug } = await params
  const post = await getPost(slug)
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: { images: [post.coverImage] },
  }
}

Nested Layouts

Layouts persist across navigation within their segment — the React tree is not unmounted, so scroll position and state are preserved:

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="flex">
      <aside className="sidebar">
        <nav>
          <a href="/dashboard">Overview</a>
          <a href="/dashboard/analytics">Analytics</a>
          <a href="/dashboard/settings">Settings</a>
        </nav>
      </aside>
      <section className="content">{children}</section>
    </div>
  )
}

Navigating from /dashboard to /dashboard/settings does not remount the sidebar — it stays mounted and only the children slot updates.

Built-in Loading and Error States

Create a loading.tsx file in any segment to show a skeleton while the page's data fetches. Next.js automatically wraps the page.tsx in a <Suspense> boundary using this component:

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="animate-pulse space-y-4">
      <div className="h-8 w-64 bg-gray-200 rounded" />
      <div className="h-32 bg-gray-200 rounded" />
    </div>
  )
}

Error boundaries must be Client Components (they use React class component error boundary behavior under the hood):

// app/dashboard/error.tsx
'use client'
 
export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="error-container">
      <h2>Something went wrong loading the dashboard.</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  )
}

Route Handlers (API Routes)

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
 
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const page = Number(searchParams.get('page')) || 1
 
  const users = await db.users.findAll({ page, limit: 20 })
  return NextResponse.json({ data: users, page })
}
 
export async function POST(request: NextRequest) {
  const body = await request.json()
  const user = await db.users.create(body)
  return NextResponse.json(user, { status: 201 })
}

Streaming with Suspense

Wrap slow Server Components in <Suspense> to stream them independently — the page shell renders immediately while each section loads in parallel:

// app/dashboard/page.tsx
import { Suspense } from 'react'
 
export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>  {/* Renders instantly */}
      <Suspense fallback={<StatsSkeleton />}>
        <StatsSection />  {/* Streams in when data is ready */}
      </Suspense>
      <Suspense fallback={<OrdersSkeleton />}>
        <RecentOrders />  {/* Streams in independently */}
      </Suspense>
    </main>
  )
}
 
async function StatsSection() {
  const stats = await getStats()  // Potentially slow DB query
  return <StatsGrid stats={stats} />
}

Common Mistakes

Using getServerSideProps in the app/ directory. It does not exist in App Router. Fetch directly in async Server Components instead.

Adding 'use client' to components that do not need it. Every 'use client' boundary ships its subtree as JavaScript to the browser. Keep Client Components as leaf nodes.

Reading params synchronously in Next.js 15. params is now a Promise — always await params before destructuring.

Not adding a root layout.tsx. Next.js will error without one. It must render the html and body tags.

Best Practices

  • Co-locate each route's data fetching inside the page component rather than in a parent layout — it keeps data dependencies explicit.
  • Use generateStaticParams for dynamic routes with a finite set of values (blog slugs, product IDs) to pre-render them at build time.
  • Place Client Components at the leaf level of your component tree — use Server Components for everything above that does not need interactivity.
  • Wrap independent slow sections in <Suspense> to parallelize streaming and give users a faster perceived load time.
  • Export metadata or generateMetadata from every page for SEO — the App Router handles title, description, and Open Graph tags natively.

Key Takeaways

  • The App Router uses the app/ directory where every folder is a URL segment and special files (layout.tsx, loading.tsx, error.tsx) control the UI.
  • Every component in the App Router is a Server Component by default — add 'use client' only when you need hooks or browser APIs.
  • Data fetching uses plain async/await in Server Components; getServerSideProps and getStaticProps no longer exist in the App Router.
  • Nested layout.tsx files persist across navigation within their segment, preserving state and scroll position without remounting.
  • loading.tsx automatically wraps the page in a Suspense boundary — no manual <Suspense> wrapper needed for the primary page content.
  • error.tsx must be a Client Component and receives an error prop and a reset function to retry rendering.
  • generateStaticParams replaces getStaticPaths — return an array of param objects and Next.js pre-renders each one at build time.
  • Wrapping independent async Server Components in <Suspense> enables streaming, so the page shell renders immediately while each section loads in parallel.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading