Understanding Next.js App Router - A Friendly Guide for Real Developers
When Next.js 13 dropped the App Router, I was skeptical.
"Why change something that works?" I thought. The Pages Router was fine. Familiar. It did the job.
Then I actually tried the App Router on a real project.
And I got it.
The App Router isn't just a different way to do routing. It's a complete rethink of how we build Next.js apps. And once you understand it, you'll never want to go back.
App Router vs Pages Router: What Changed?
The Old Way (Pages Router)
pages/
├── index.js → /
├── about.js → /about
├── blog/
│ ├── index.js → /blog
│ └── [slug].js → /blog/:slug
└── api/
└── users.js → /api/usersSimple. Straightforward. One file = one route.
The New Way (App Router)
app/
├── page.tsx → /
├── layout.tsx → Root layout
├── loading.tsx → Loading UI
├── error.tsx → Error UI
├── about/
│ └── page.tsx → /about
└── blog/
├── page.tsx → /blog
├── loading.tsx → /blog loading
└── [slug]/
├── page.tsx → /blog/:slug
└── error.tsx → /blog/:slug errorMore files. But each file has a specific purpose.
Key Concepts: The Mental Model
Think of the App Router as building a house:
- page.tsx = The main room (what users see)
- layout.tsx = The foundation/frame (wraps pages)
- loading.tsx = Temporary scaffolding (loading states)
- error.tsx = Safety systems (error handling)
Server Components: The Game Changer
This is the big one. In the App Router, components are Server Components by default.
What This Means
// This runs on the SERVER
export default async function BlogPage() {
const posts = await fetch('https://api.example.com/posts')
.then(r => r.json())
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
)
}No useEffect. No loading spinners. No client-side fetch.
The data is fetched on the server, the HTML is rendered, and sent to the browser complete.
When You Need Client Components
Only when you need interactivity:
'use client' // This directive makes it a Client Component
import { useState } from 'react'
export default function LikeButton() {
const [likes, setLikes] = useState(0)
return (
<button onClick={() => setLikes(likes + 1)}>
Likes: {likes}
</button>
)
}Layouts: Persistent UI Made Simple
Layouts wrap pages and persist across navigation.
// app/layout.tsx - Root layout (required)
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<header>My Site</header>
<main>{children}</main>
<footer>© 2025</footer>
</body>
</html>
)
}Nested Layouts
// app/blog/layout.tsx - Blog-specific layout
export default function BlogLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div>
<aside>Blog sidebar</aside>
<div>{children}</div>
</div>
)
}Result: Blog pages get both the root layout AND the blog layout.
Loading States: Built Into Routing
No more conditional rendering for loading states.
// app/blog/loading.tsx
export default function Loading() {
return (
<div>
<div className="animate-pulse">
<div className="h-4 mb-4 bg-gray-200 rounded"></div>
<div className="h-4 mb-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded"></div>
</div>
</div>
)
}This automatically shows while page.tsx is loading.
Error Handling: Automatic Error Boundaries
// app/blog/error.tsx
'use client' // Error components must be Client Components
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
)
}Catches errors in the route segment automatically.
Route Groups: Organization Without URLs
Group routes without affecting the URL structure.
app/
├── (marketing)/
│ ├── about/
│ │ └── page.tsx → /about
│ └── pricing/
│ └── page.tsx → /pricing
└── (shop)/
├── products/
│ └── page.tsx → /products
└── cart/
└── page.tsx → /cartThe parentheses are ignored in the URL. Just for organization.
Parallel Routes: Complex Layouts Made Easy
Show multiple pages in the same layout.
app/
├── dashboard/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── @analytics/
│ │ └── page.tsx
│ └── @team/
│ └── page.tsx// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div>
<div>{children}</div>
<div>{analytics}</div>
<div>{team}</div>
</div>
)
}All three components render on the same page.
Data Fetching: The New Paradigm
Server Components: Fetch Directly
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
cache: 'force-cache' // or 'no-cache'
})
if (!res.ok) {
throw new Error('Failed to fetch posts')
}
return res.json()
}
export default async function BlogPage() {
const posts = await getPosts()
return (
<div>
{posts.map(post => (
<article key={post.id}>{post.title}</article>
))}
</div>
)
}Request Deduplication
Multiple components can call the same function. Next.js dedupes automatically.
// Both components call getUser(id)
// Only makes ONE network request
async function UserProfile({ id }: { id: string }) {
const user = await getUser(id)
return <div>{user.name}</div>
}
async function UserPosts({ id }: { id: string }) {
const user = await getUser(id)
return <div>Posts by {user.name}</div>
}Static and Dynamic Rendering
Static by Default
Pages are statically generated at build time when possible.
Dynamic When Needed
Use dynamic functions to opt into dynamic rendering:
import { cookies, headers } from 'next/headers'
export default async function Page() {
const cookieStore = cookies()
const theme = cookieStore.get('theme')
return <div>Theme: {theme?.value}</div>
}Route Handlers: API Routes Evolved
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET() {
const posts = await getPosts()
return NextResponse.json(posts)
}
export async function POST(request: NextRequest) {
const data = await request.json()
const post = await createPost(data)
return NextResponse.json(post)
}More explicit. Better TypeScript support.
Metadata: SEO Made Simple
import { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Blog',
description: 'My awesome blog',
openGraph: {
title: 'Blog',
description: 'My awesome blog',
images: ['/og-image.jpg'],
},
}
export default function BlogPage() {
return <div>Blog content</div>
}Dynamic Metadata
export async function generateMetadata({
params,
}: {
params: { slug: string }
}): Promise<Metadata> {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.excerpt,
}
}Migration from Pages Router
Don't migrate everything at once. Use gradually:
1. Install Next.js 13+
npm install next@latest react@latest react-dom@latest2. Create app Directory
Keep your pages directory. Add app alongside it.
3. Move Routes Gradually
Start with simple pages. Move complex ones later.
4. Update Imports
// Pages Router
import { useRouter } from 'next/router'
import Link from 'next/link'
// App Router
import { useRouter } from 'next/navigation' // Different import!
import Link from 'next/link' // SameCommon Patterns
Loading with Suspense
import { Suspense } from 'react'
export default function Page() {
return (
<div>
<h1>Posts</h1>
<Suspense fallback={<PostsSkeleton />}>
<Posts />
</Suspense>
</div>
)
}
async function Posts() {
const posts = await getPosts()
return (
<div>
{posts.map(post => (
<article key={post.id}>{post.title}</article>
))}
</div>
)
}Streaming
Show parts of the page as they load:
export default function Page() {
return (
<div>
<Header /> {/* Shows immediately */}
<Suspense fallback={<Skeleton />}>
<SlowContent /> {/* Streams in when ready */}
</Suspense>
</div>
)
}Best Practices
1. Use Server Components by Default
Only use Client Components when you need:
- Event handlers (
onClick,onChange) - State (
useState,useReducer) - Effects (
useEffect) - Browser APIs
2. Keep Client Components Small
// Good: Small client component
function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false)
return (
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
)
}
// Good: Server component with client child
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
<LikeButton postId={post.id} />
</article>
)
}3. Use loading.tsx for Better UX
Every route that fetches data should have a loading state.
4. Handle Errors Gracefully
Add error.tsx files for better error handling.
When App Router Clicked for Me
I was building a blog. With Pages Router, I had:
- Client-side data fetching
- Loading states everywhere
- Complex SEO setup
- Slow initial loads
With App Router:
- Data fetched on server
- Loading states built-in
- SEO automatic
- Fast, fully-rendered pages
Same functionality. Better experience. Cleaner code.
The Bottom Line
App Router feels different because it IS different. It's:
- Server-first instead of client-first
- Declarative instead of imperative
- Built-in instead of configured
The learning curve is real. But the benefits are worth it:
- Better performance
- Simpler data fetching
- Built-in loading and error states
- Automatic SEO
- Future-proof architecture
Don't fight it. Embrace the server. Your users (and your Lighthouse scores) will thank you.
---
Ready to dive deeper? Check out my guide on when to use Server vs Client Components in Next.js.Want articles like this in your inbox?
Join developers and founders who get practical insights on frontend, SaaS, and building better products.
Written by Salman Izhar
Frontend Developer specializing in React, Next.js, and building high-converting web applications.
Learn More