Understanding Next.js App Router: A Developer's Guide
Next.jsApp RouterServer Components

Understanding Next.js App Router: A Developer's Guide

January 23, 2025
13 min read
Salman Izhar

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)

text
pages/
├── index.js          → /
├── about.js          → /about
├── blog/
│   ├── index.js      → /blog
│   └── [slug].js     → /blog/:slug
└── api/
    └── users.js      → /api/users

Simple. Straightforward. One file = one route.

The New Way (App Router)

text
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 error

More 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

tsx
// 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:

tsx
'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.

tsx
// 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

tsx
// 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.

tsx
// 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

tsx
// 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.

text
app/
├── (marketing)/
│   ├── about/
│   │   └── page.tsx     → /about
│   └── pricing/
│       └── page.tsx     → /pricing
└── (shop)/
    ├── products/
    │   └── page.tsx     → /products
    └── cart/
        └── page.tsx     → /cart

The parentheses are ignored in the URL. Just for organization.

Parallel Routes: Complex Layouts Made Easy

Show multiple pages in the same layout.

text
app/
├── dashboard/
│   ├── layout.tsx
│   ├── page.tsx
│   ├── @analytics/
│   │   └── page.tsx
│   └── @team/
│       └── page.tsx
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

tsx
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.

tsx
// 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:

tsx
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

tsx
// 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

tsx
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

tsx
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+

bash
npm install next@latest react@latest react-dom@latest

2. 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

tsx
// 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' // Same

Common Patterns

Loading with Suspense

tsx
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:

tsx
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

tsx
// 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.
Get More Like This

Want articles like this in your inbox?

Join developers and founders who get practical insights on frontend, SaaS, and building better products.

S

Written by Salman Izhar

Frontend Developer specializing in React, Next.js, and building high-converting web applications.

Learn More