React

Next.js App Router Complete Guide (2026)

Master the Next.js App Router: layouts, loading UI, error boundaries, parallel routes, intercepting routes, streaming, and the mental model that makes it all click.

May 11, 202614 min read
Share:
Next.js App Router Complete Guide (2026)

The App Router has been stable since Next.js 13.4, is the default in Next.js 14 and 15, and it's still confusing the hell out of people. Not because it's bad — it's genuinely powerful — but because it requires a different mental model than the Pages Router.

This guide covers every file convention, every routing pattern, the right mental model for Server vs Client Components, and the common mistakes that trip people up. By the end, the App Router will make sense.

The Core Mental Model

Everything in the App Router starts with understanding the component tree and where it runs:

layout.tsx (Server Component — runs on server)
  loading.tsx (Server Component — Suspense boundary)
    error.tsx (Client Component — error boundary)
      page.tsx (Server Component by default)
        'use client' components (Client Components)

The key insight: Server Components are the default. They render on the server, have direct access to databases and APIs, and send only HTML + minimal JS to the browser. When you need interactivity, you opt into Client Components with 'use client'.

This is the opposite of the Pages Router, where everything was client-first and you called APIs explicitly.

File Conventions

The App Router uses special filenames to create routing behavior:

FilePurpose
page.tsxThe route's UI — makes the URL accessible
layout.tsxWraps pages, persists across navigation
loading.tsxAutomatic Suspense boundary while data loads
error.tsxError boundary for the segment
not-found.tsx404 UI for the segment
template.tsxLike layout but remounts on navigation
route.tsAPI route (no UI)
default.tsxFallback for parallel routes

These files must be in the app/ directory. You can nest them in any folder.

Layouts

Layouts wrap their children and persist across navigations — they don't re-render when you navigate between pages that share the same layout.

// app/layout.tsx — root layout (required)
import type { Metadata } from 'next'
 
export const metadata: Metadata = {
  title: { template: '%s | StackNotice', default: 'StackNotice' },
  description: 'Full-stack React guides',
}
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <nav>/* persistent nav */</nav>
        {children}
        <footer>/* persistent footer */</footer>
      </body>
    </html>
  )
}

Nested layouts:

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="flex">
      <aside>/* dashboard sidebar */</aside>
      <main className="flex-1">{children}</main>
    </div>
  )
}

Any page under /dashboard/* gets this sidebar. The root layout wraps it all. Layouts compose automatically.

When to Use template.tsx Instead

template.tsx works like layout.tsx but remounts on every navigation. Use it when you need to run effects on route change:

// app/dashboard/template.tsx
'use client'
 
import { useEffect } from 'react'
import { trackPageView } from '@/lib/analytics'
 
export default function DashboardTemplate({
  children,
}: {
  children: React.ReactNode
}) {
  useEffect(() => {
    trackPageView()
  }, [])
 
  return <>{children}</>
}

If layout.tsx works, prefer it — it's more performant. Use template.tsx only when you need lifecycle behavior on navigation.

Loading UI

loading.tsx automatically wraps your page.tsx in a Suspense boundary. While the page fetches data, the loading UI shows:

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="space-y-4">
      <div className="h-8 w-48 animate-pulse rounded bg-gray-200" />
      <div className="h-4 w-full animate-pulse rounded bg-gray-200" />
      <div className="h-4 w-3/4 animate-pulse rounded bg-gray-200" />
    </div>
  )
}
// app/dashboard/page.tsx
export default async function DashboardPage() {
  // This fetch is what triggers loading.tsx while it resolves
  const data = await fetchDashboardData()
 
  return <Dashboard data={data} />
}

The loading UI appears instantly, before the data resolves. No useEffect, no manual loading state.

Granular Loading with Suspense

For granular control within a page, use <Suspense> directly:

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { MetricsSkeleton } from '@/components/skeletons'
 
export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      {/* Fast data loads instantly */}
      <Suspense fallback={<MetricsSkeleton />}>
        <SlowMetrics />
      </Suspense>
      {/* Very slow data has its own boundary */}
      <Suspense fallback={<p>Loading chart...</p>}>
        <RevenueChart />
      </Suspense>
    </div>
  )
}
 
// These are Server Components that fetch independently
async function SlowMetrics() {
  const metrics = await fetchMetrics() // takes ~300ms
  return <MetricsGrid data={metrics} />
}
 
async function RevenueChart() {
  const data = await fetchRevenueData() // takes ~800ms
  return <Chart data={data} />
}

Both SlowMetrics and RevenueChart fetch in parallel. Each shows its own skeleton independently as it resolves. This is streaming — the page sends HTML incrementally as each piece resolves.

Error Boundaries

error.tsx catches errors thrown in the segment's page.tsx or child Server Components. It must be a Client Component because it uses React error boundary APIs:

// app/dashboard/error.tsx
'use client'
 
import { useEffect } from 'react'
 
export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    // Log to error reporting service
    console.error(error)
  }, [error])
 
  return (
    <div className="flex flex-col items-center gap-4 p-8">
      <h2 className="text-xl font-bold">Something went wrong</h2>
      <p className="text-gray-500">{error.message}</p>
      <button
        onClick={reset}
        className="rounded bg-blue-500 px-4 py-2 text-white"
      >
        Try again
      </button>
    </div>
  )
}

reset() re-renders the segment — useful for transient errors like network failures.

For errors in the root layout, create app/global-error.tsx (it must include <html> and <body>).

Route Groups — (parentheses)

Route groups let you organize files without affecting the URL. Wrap a folder name in parentheses:

app/
  (marketing)/
    page.tsx        → /
    about/page.tsx  → /about
    layout.tsx      → marketing layout (no auth)
  (app)/
    dashboard/
      page.tsx      → /dashboard
    settings/
      page.tsx      → /settings
    layout.tsx      → app layout (with auth check)

Both groups share the same URL namespace, but they can have different layouts. This is the cleanest way to have a public marketing layout and an authenticated app layout without URL segments.

// app/(app)/layout.tsx
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
 
export default async function AppLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await auth()
 
  if (!session) {
    redirect('/login')
  }
 
  return (
    <div>
      <AppNavbar user={session.user} />
      {children}
    </div>
  )
}

Every route under (app)/ is automatically protected. No middleware needed for this use case.

Dynamic Routes

app/
  blog/
    [slug]/
      page.tsx      → /blog/any-slug
    [...slug]/
      page.tsx      → /blog/a/b/c (catch-all)
    [[...slug]]/
      page.tsx      → /blog and /blog/a/b/c (optional catch-all)
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
 
type Props = {
  params: Promise<{ slug: string }>
}
 
export default async function BlogPost({ params }: Props) {
  const { slug } = await params  // params is now async in Next.js 15
 
  const post = await getPost(slug)
 
  if (!post) {
    notFound()  // triggers not-found.tsx
  }
 
  return <Article post={post} />
}
 
// Generate static pages at build time
export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map(post => ({ slug: post.slug }))
}

Note: in Next.js 15, params and searchParams are now Promises — you must await them.

Parallel Routes — @folder

Parallel routes render multiple pages in the same layout simultaneously. Each parallel slot is a folder prefixed with @:

app/
  layout.tsx
  page.tsx
  @modal/
    login/
      page.tsx    → rendered alongside main content
    default.tsx   → shown when no modal is active
  @sidebar/
    page.tsx
    default.tsx
// app/layout.tsx
export default function Layout({
  children,
  modal,
  sidebar,
}: {
  children: React.ReactNode
  modal: React.ReactNode
  sidebar: React.ReactNode
}) {
  return (
    <div>
      {sidebar}
      <main>{children}</main>
      {modal}
    </div>
  )
}

default.tsx in each slot renders when the slot has no active match — essential to prevent layout crashes when navigating.

Intercepting Routes — (.) syntax

Intercepting routes let you display a route's content inside the current page (like a modal) while keeping the full page accessible via direct URL.

app/
  photos/
    [id]/
      page.tsx          → /photos/1 (full page)
  @modal/
    (.)photos/[id]/
      page.tsx          → /photos/1 when navigated client-side (modal)
    default.tsx
// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/modal'
import { PhotoView } from '@/components/photo-view'
 
export default async function PhotoModal({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const photo = await getPhoto(id)
 
  return (
    <Modal>
      <PhotoView photo={photo} />
    </Modal>
  )
}

The intercepting route conventions:

  • (.) — intercept segment at the same level
  • (..) — intercept segment one level up
  • (..)(..) — two levels up
  • (...) — intercept from the root app/

Metadata API

Static metadata:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
 
export const metadata: Metadata = {
  title: 'My Post',
  description: 'Post description',
}

Dynamic metadata (for pages that need fetched data):

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}): Promise<Metadata> {
  const { slug } = await params
  const post = await getPost(slug)
 
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [{ url: post.coverImage }],
    },
    twitter: {
      card: 'summary_large_image',
    },
  }
}

Next.js deduplicates the data fetch — if generateMetadata and the page component both call getPost(slug), it's fetched once.

Data Fetching in Server Components

Server Components can fetch data directly — no useEffect, no API route:

// app/dashboard/page.tsx
import { db } from '@/lib/db'
import { auth } from '@/lib/auth'
 
export default async function DashboardPage() {
  const session = await auth()
 
  // Direct DB access in the component
  const posts = await db.post.findMany({
    where: { authorId: session.user.id },
    orderBy: { createdAt: 'desc' },
    take: 10,
  })
 
  return <PostList posts={posts} />
}

For caching control:

// Cache for 60 seconds, then revalidate
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 60 }
})
 
// Never cache (always fresh)
const data = await fetch('https://api.example.com/data', {
  cache: 'no-store'
})
 
// Cache indefinitely (static)
const data = await fetch('https://api.example.com/data', {
  cache: 'force-cache'
})

Server vs Client Components: Decision Rules

Use a Server Component when:

  • Fetching data
  • Accessing environment variables or secrets
  • The component has no interactivity
  • You want to reduce client bundle size

Use a Client Component when:

  • Using useState, useReducer, useEffect
  • Using browser APIs (window, localStorage, document)
  • Using event handlers (onClick, onChange)
  • Using third-party libraries that use browser APIs
// Server Component — data fetching, no client needed
async function UserProfile({ userId }: { userId: string }) {
  const user = await getUser(userId)
  return (
    <div>
      <h1>{user.name}</h1>
      {/* Pass data down to a Client Component */}
      <FollowButton userId={userId} initialFollowing={user.isFollowing} />
    </div>
  )
}
 
// Client Component — interactivity only
'use client'
function FollowButton({ userId, initialFollowing }: { userId: string; initialFollowing: boolean }) {
  const [following, setFollowing] = useState(initialFollowing)
 
  async function toggle() {
    setFollowing(!following)
    await toggleFollow(userId)
  }
 
  return (
    <button onClick={toggle}>
      {following ? 'Unfollow' : 'Follow'}
    </button>
  )
}

The pattern: Server Components fetch, Client Components handle interactivity. Pass data from server to client via props.

Common Mistakes

Mistake 1: Wrapping everything in 'use client'

The most common anti-pattern. When you add 'use client' to a component, all its imports also become client-side — you lose the Server Component benefits and bundle-size savings.

Instead, push 'use client' down to the leaf components that actually need it.

Mistake 2: Not awaiting params in Next.js 15

// ❌ Next.js 14 style — breaks in 15
export default function Page({ params }: { params: { slug: string } }) {
  const { slug } = params  // wrong — params is a Promise in Next.js 15
}
 
// ✅ Next.js 15 style
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params  // correct
}

Mistake 3: Forgetting default.tsx for parallel routes

Without default.tsx in a parallel route slot, navigating to a route that doesn't match the slot causes a 404. Always add a default.tsx to every @slot folder.

Mistake 4: Fetching in Client Components when Server Components would work

// ❌ Old habits — fetching in a client component
'use client'
function Posts() {
  const [posts, setPosts] = useState([])
  useEffect(() => {
    fetch('/api/posts').then(r => r.json()).then(setPosts)
  }, [])
  return <PostList posts={posts} />
}
 
// ✅ Server Component — simpler, faster, no loading state
async function Posts() {
  const posts = await db.post.findMany()
  return <PostList posts={posts} />
}

The App Router pairs cleanly with React Server Actions for mutations — no API routes needed for most CRUD operations. For authentication that works with the App Router's layout-based protection, see the Next.js auth guide. For full-stack patterns combining all these concepts, check out the Next.js full-stack tutorial.

#next-js#react#app-router#typescript#server-components
Share:

Enjoyed this article?

Join 2,400+ developers getting weekly insights on Claude Code, React, and AI tools.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.