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/
│ └── [slug].js → /blog/:slug
└── _app.js → Root component
Simple file-based routing. One file = one route. Easy to understand.
The New Way (App Router)
app/
├── page.tsx → /
├── layout.tsx → Shared layout
├── about/
│ └── page.tsx → /about
└── blog/
└── [slug]/
└── page.tsx → /blog/:slug
Folders for routes. Special files for different purposes. More structure, more power.
Why the Change Makes Sense
The Pages Router had problems:
- Shared layouts required awkward
_app.jshacks - Loading states meant custom logic
- Error boundaries were painful
- Everything was client-side by default
App Router fixes all of this.
Core Concepts: What You Need to Know
1. Folders Define Routes
In App Router, folders create URL segments.
app/
├── dashboard/ → /dashboard
│ └── settings/ → /dashboard/settings
│ └── page.tsx
The page.tsx file makes it a route. Without it, the folder is just for organization.
2. Special Files with Special Powers
Each folder can have these special files:
- page.tsx - The UI for that route
- layout.tsx - Shared layout that wraps children
- loading.tsx - Loading UI (automatic Suspense)
- error.tsx - Error boundary
- not-found.tsx - 404 page
- template.tsx - Like layout, but re-renders on navigation
app/
└── dashboard/
├── page.tsx → Main UI
├── layout.tsx → Wraps all dashboard pages
├── loading.tsx → Shows while loading
└── error.tsx → Catches errors
3. Layouts: The Game Changer
Layouts are persistent. They don't re-render when you navigate between pages.
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
{children}
)
}
Now every page inside app/dashboard/* automatically gets this layout. The sidebar stays mounted. State persists. No flicker.
4. Nested Layouts
Layouts stack. Each level can have its own.
app/
├── layout.tsx → Root layout (all pages)
└── dashboard/
├── layout.tsx → Dashboard layout
└── settings/
├── layout.tsx → Settings layout
└── page.tsx
When you visit /dashboard/settings:
- Root layout wraps everything
- Dashboard layout wraps settings
- Settings layout wraps the page
Three layers. All automatic.
Server Components by Default
Here's the big shift: everything is a Server Component unless you opt out.
// This runs on the server
export default async function BlogPost({ params }) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`)
.then(r => r.json())
return (
{post.title}
{post.content}
)
}
No useEffect. No loading states. Just fetch and render. The HTML is sent to the browser fully rendered.
When You Need Client Components
Add 'use client' when you need:
- State (useState, useReducer)
- Effects (useEffect)
- Event handlers (onClick, onChange)
- Browser APIs (localStorage, window)
- Context
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
)
}
Loading States Made Easy
Remember adding loading spinners everywhere? Not anymore.
// app/dashboard/loading.tsx
export default function Loading() {
return (
)
}
That's it. Next.js automatically shows this while page.tsx loads. No Suspense wrapping. No manual state. It just works.
Error Handling Built In
// app/dashboard/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
Something went wrong!
{error.message}
)
}
Any error in dashboard pages gets caught here. Automatic error boundaries for every route.
Dynamic Routes
Dynamic routes work similarly, but the syntax changed slightly.
app/
└── blog/
└── [slug]/
└── page.tsx → /blog/anything
Access params in the component:
export default async function BlogPost({
params,
}: {
params: { slug: string }
}) {
const post = await getPost(params.slug)
return {post.content}
}
Catch-All Routes
app/
└── docs/
└── [...slug]/
└── page.tsx → /docs/anything/any/depth
Optional Catch-All
app/
└── shop/
└── [[...slug]]/
└── page.tsx → /shop AND /shop/anything
Route Groups
Want to organize routes without affecting URLs?
app/
├── (marketing)/
│ ├── about/
│ └── contact/
└── (dashboard)/
├── settings/
└── profile/
The parentheses mean the folder name isn't in the URL:
/about(not/marketing/about)/settings(not/dashboard/settings)
Great for organization and different layouts.
// app/(marketing)/layout.tsx
export default function MarketingLayout({ children }) {
return (
{children}
)
}
// app/(dashboard)/layout.tsx
export default function DashboardLayout({ children }) {
return (
{children}
)
}
Different layouts. Same URL structure.
Parallel Routes
This is advanced, but powerful. Show multiple pages in one layout.
app/
└── dashboard/
├── @analytics/
│ └── page.tsx
├── @revenue/
│ └── page.tsx
└── layout.tsx
// app/dashboard/layout.tsx
export default function Layout({
children,
analytics,
revenue,
}: {
children: React.ReactNode
analytics: React.ReactNode
revenue: React.ReactNode
}) {
return (
{analytics}
{revenue}
{children}
)
}
Three independent sections. Each can load separately. Show different loading/error states.
Metadata for SEO
Pages Router needed next-seo or manual tags. App Router has built-in metadata.
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
export async function generateMetadata({ params }): Promise {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.image],
},
}
}
export default async function BlogPost({ params }) {
const post = await getPost(params.slug)
return {post.content}
}
Dynamic metadata based on the page data. Perfect for SEO.
Migration: Should You Switch?
Stick with Pages Router if:
- Your app works fine
- You're close to shipping
- Team doesn't want to learn new patterns
Switch to App Router if:
- Starting a new project
- Need better layouts
- Want server components
- SEO is critical
You can use both in the same project during migration.
Real-World Example: Dashboard
Here's a complete dashboard structure:
app/
├── layout.tsx → Root
├── (auth)/
│ ├── login/
│ │ └── page.tsx
│ └── layout.tsx → Auth pages layout
└── (dashboard)/
├── layout.tsx → Dashboard layout
├── loading.tsx → Dashboard loading
├── error.tsx → Dashboard errors
├── page.tsx → /dashboard
├── settings/
│ ├── page.tsx → /dashboard/settings
│ ├── profile/
│ │ └── page.tsx → /dashboard/settings/profile
│ └── security/
│ └── page.tsx → /dashboard/settings/security
└── analytics/
└── page.tsx → /dashboard/analytics
Clean organization. Proper layouts. Automatic loading/error handling.
The Bottom Line
App Router feels weird at first. More files. New patterns. Different mental model.
But once it clicks:
- Layouts are easy
- Loading states are automatic
- Errors are handled
- SEO just works
- Server components are fast
It's not just different. It's better.
Give it a real try. Build something. You'll see why Next.js made this change.
---
Ready to dive deeper? Check out my guide on how Context API fits into modern Next.js App Router projects.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