The Next.js App Router represents a fundamental shift in how we build React applications. Built on top of React Server Components (RSC), it changes where and how your code runs, moving rendering logic to the server by default while preserving client-side interactivity where you need it.
How the App Router Works
The App Router uses a file-system-based routing convention inside the app/ directory. Each route is a folder containing a page.tsx file, with optional layout.tsx, loading.tsx, and error.tsx files for shared UI, streaming fallbacks, and error boundaries.
app/ layout.tsx # Root layout (wraps all pages) page.tsx # Home route (/) dashboard/ layout.tsx # Dashboard layout page.tsx # /dashboard settings/ page.tsx # /dashboard/settingsLayouts are preserved across navigations. A layout wraps its children and does not re-render when users navigate between sibling routes. This eliminates redundant data fetching and preserves UI state in shared components like sidebars.
React Server Components by Default
Every component inside app/ is a Server Component unless you explicitly mark it otherwise. Server Components run on the server at request time (or build time for static routes), which means they can directly access databases, read files, and call internal services without exposing that logic to the browser.
// app/posts/page.tsx — this is a Server Componentimport { db } from "@/lib/database";
export default async function PostsPage() { const posts = await db.post.findMany({ orderBy: { createdAt: "desc" }, take: 20, });
return ( <main> <h1>Recent Posts</h1> {posts.map(post => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.summary}</p> </article> ))} </main> );}No useEffect, no loading state management, no API route intermediary. The component fetches data as part of its render, and the HTML is streamed to the client.
When to Use ‘use client’
Add the 'use client' directive at the top of a file when you need browser APIs, React hooks like useState or useEffect, or event handlers. The key principle: push 'use client' as far down the component tree as possible.
"use client";
import { useState } from "react";
export function LikeButton({ postId }: { postId: string }) { const [liked, setLiked] = useState(false);
return ( <button onClick={() => setLiked(!liked)}> {liked ? "Liked" : "Like"} </button> );}This button is a Client Component, but the parent page that renders it remains a Server Component. Only the interactive leaf nodes ship JavaScript to the browser.
Data Fetching with Fetch and Caching
The App Router extends the native fetch API with caching and revalidation controls:
// Cached indefinitely (static data)const data = await fetch("https://api.example.com/config", { cache: "force-cache",});
// Revalidate every 60 seconds (ISR-style)const posts = await fetch("https://api.example.com/posts", { next: { revalidate: 60 },});
// Always fresh (dynamic data)const user = await fetch("https://api.example.com/me", { cache: "no-store",});Next.js automatically deduplicates identical fetch requests within a single render pass. If multiple components request the same URL, only one network call is made.
Streaming with Suspense
Suspense boundaries allow you to progressively render a page. Wrap slow components in <Suspense> and provide a fallback. The shell renders immediately while the suspended content streams in when ready.
import { Suspense } from "react";import { PostFeed } from "./post-feed";import { Recommendations } from "./recommendations";
export default function DashboardPage() { return ( <main> <h1>Dashboard</h1> <Suspense fallback={<p>Loading feed...</p>}> <PostFeed /> </Suspense> <Suspense fallback={<p>Loading recommendations...</p>}> <Recommendations /> </Suspense> </main> );}Each Suspense boundary streams independently. The feed might resolve in 200ms while recommendations take 800ms, and users see each section appear as it becomes ready.
Migrating from Pages Router
Migration does not need to happen all at once. The app/ and pages/ directories can coexist:
- Start with layouts. Move your
_app.tsxwrapper logic intoapp/layout.tsx. - Migrate static pages first. Pages without complex client state (about, docs, marketing) are the easiest to convert.
- Replace
getServerSidePropswith async components. The data fetching that lived ingetServerSidePropsmoves directly into the component body. - Replace
getStaticPropswith cached fetch orgenerateStaticParams. Static generation usesfetchwithforce-cacheor thegenerateStaticParamsfunction for dynamic routes. - Extract client interactivity. Identify the interactive parts of your pages, extract them into
'use client'components, and keep the parent as a Server Component.
The App Router is not just a new API surface. It changes the rendering model of your application. Server Components reduce bundle size, eliminate client-server waterfalls, and simplify data access. Start with new routes in app/, migrate incrementally, and push client boundaries to the leaves.