Next.js App Router — What Changed from Pages Router
Server Components, layouts, data fetching, and routing in Next.js App Router. A practical guide for developers making the switch.

If you've worked with Next.js, you've probably hit this moment: "How do I do this in the App Router?" Code that worked fine in Pages Router breaks in App Router, or there's a pattern that only exists in App Router. The official docs are thorough but massive, making it hard to find what you need quickly.
This guide covers the core App Router concepts from a practical perspective. Less "here's the theory" and more "here's how it actually works."
Folders Are Routes
The most intuitive part of App Router. The folder structure inside app/ maps directly to URL paths.
app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/
│ └── page.tsx → /blog/my-post
The key detail: a folder needs a page.tsx file to be recognized as a route. Create a folder without page.tsx and navigating to that path gives a 404. This means you can colocate route-related files (components, utils) in the same folder without polluting the routing.
Dynamic routes use bracket syntax. [slug] for a single parameter, [...slug] for catch-all, [[...slug]] for optional catch-all. Similar to Pages Router so far, but how you access the parameters changed.
// In App Router, params is a Promise
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
// ...
}
Starting in Next.js 15, params and searchParams became async. Slightly more verbose, but it aligns with the async nature of Server Components.
Server Components Are the Default
The biggest change in App Router, and the biggest source of confusion.
Every component inside app/ is a Server Component by default. It renders on the server and doesn't get included in the JavaScript bundle. What does that mean in practice?
// This component runs only on the server
export default async function UserList() {
// Direct DB queries — yes, right in the component
const users = await db.query('SELECT * FROM users');
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Since it runs on the server, you can access databases directly, use API keys and secrets safely, and import heavy libraries without affecting client bundle size. Genuinely powerful.
The constraints: no useState, no useEffect. No event handlers (onClick, etc.). No browser APIs. If you need interactivity, make it a Client Component.
"use client"; // This single line makes it a Client Component
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
The principle: Keep everything as Server Components by default. Extract only the interactive parts into Client Components. Slapping "use client" on an entire page throws away App Router's main advantage.
A common mistake: importing a Server Component inside a Client Component requires care. The workaround is passing it through children props.
// ✅ Pass Server Component as children
<ClientWrapper>
<ServerComponent />
</ClientWrapper>
layout.tsx Changed Layout Management
Pages Router used _app.tsx for shared layouts. Every page transition re-rendered the whole thing, and nested layouts required custom patterns.
App Router's layout.tsx handles this cleanly.
// app/layout.tsx — Root layout (required)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
);
}
Layouts nest automatically. Create app/blog/layout.tsx and it wraps the blog section inside the root layout. And when you navigate between pages, layouts don't re-render. State persists. Sidebar scroll position, search input values — they stay put.
There's also template.tsx, which remounts on every navigation. Useful for page transition animations or per-page resets. Most of the time, layout.tsx is all you need.
Data Fetching — fetch Got Simpler
Pages Router required dedicated functions: getServerSideProps, getStaticProps. App Router lets you call fetch directly inside components.
export default async function ProductPage() {
const res = await fetch('https://api.example.com/products');
const products = await res.json();
return <ProductList products={products} />;
}
Components can be async functions — that's the power of Server Components. No more useEffect with fetch inside, no loading state management boilerplate, no separate error handling wrappers.
Caching behavior changed in Next.js 15. Previously, fetch was cached by default. Now the default is no caching (no-store). You opt into caching explicitly.
// Explicit caching
fetch('https://api.example.com/data', { cache: 'force-cache' });
// Time-based revalidation
fetch('https://api.example.com/data', { next: { revalidate: 3600 } });
Loading and Error Handling via File Conventions
An underrated App Router strength. Create files with specific names and loading UI and error handling work automatically.
app/blog/
├── page.tsx → main content
├── loading.tsx → shown while loading
├── error.tsx → shown on error
└── not-found.tsx → shown for 404
loading.tsx runs on top of React Suspense. While data is fetching, this component shows automatically, then swaps to the real content when ready. Add a skeleton UI here and the user experience improves significantly.
error.tsx catches errors in that route segment and shows a fallback. The layout stays intact, so users don't lose their place entirely. Note: error.tsx must be a Client Component — "use client" is required.
Server Actions — Server Logic Without API Routes
Handle form submissions and data mutations without a separate API route.
// app/contact/page.tsx
export default function ContactPage() {
async function submitForm(formData: FormData) {
"use server";
const email = formData.get("email");
await db.insert({ email });
}
return (
<form action={submitForm}>
<input name="email" type="email" />
<button type="submit">Submit</button>
</form>
);
}
The "use server" directive means the function runs on the server. Calling it from the client automatically sends a request to the server. No separate API route, no fetch call, no manual error handling plumbing.
That said, you don't need Server Actions for everything. External-facing APIs (mobile apps, third parties) still belong in Route Handlers (app/api/).
Static Generation — generateStaticParams
Specifies which pages to generate statically at build time. The App Router equivalent of Pages Router's getStaticPaths.
export function generateStaticParams() {
return [
{ slug: 'first-post' },
{ slug: 'second-post' },
];
}
For content like blog posts that are known ahead of time, pre-generating HTML at build time speeds up loading. The dynamicParams option controls behavior for paths not in the list.
Metadata — SEO Got Easier
Pages Router required importing the Head component or using libraries like next-seo. App Router has a built-in metadata API.
// Static metadata
export const metadata: Metadata = {
title: "Blog",
description: "Blog listing page",
};
// Dynamic metadata
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = getPost(slug);
return {
title: post.title,
description: post.description,
openGraph: { title: post.title, images: [post.thumbnail] },
};
}
generateMetadata lets you generate meta tags dynamically based on data. OG images, Twitter cards, canonical URLs — all handled through this API.
Migration — You Don't Have to Do It All at Once
Good news for anyone wondering whether to switch: both routers can coexist. The pages/ and app/ directories can live side by side. Build new pages in app/ and migrate existing ones incrementally.
For new projects, go with App Router without hesitation. As of 2026, App Router is the Next.js default, and all new features are App Router-first. Pages Router won't disappear overnight, but the direction is clear.