Next.js 15 App Router Deep Dive

by Ada
Last updated on May 24, 2024

Next.js 15 App Router Deep Dive

Master the Next.js 15 App Router with React Server Components, streaming, and modern patterns. This comprehensive guide covers everything you need to build production-ready applications.

Why App Router?

The App Router introduces powerful new features:

  • React Server Components by default
  • Streaming and Suspense support
  • Improved data fetching patterns
  • Built-in loading and error states
  • Nested layouts and templates
  • Parallel and intercepting routes

File-Based Routing

Basic Structure

app/
├── page.tsx              # / route
├── about/
│   └── page.tsx         # /about route
├── blog/
│   ├── page.tsx         # /blog route
│   └── [slug]/
│       └── page.tsx     # /blog/[slug] route
└── layout.tsx           # Root layout

Special Files

app/
├── layout.tsx           # Shared layout
├── page.tsx            # Page component
├── loading.tsx         # Loading UI
├── error.tsx           # Error UI
├── not-found.tsx       # 404 UI
├── template.tsx        # Re-mounted layout
└── route.ts            # API endpoint

Layouts

Root Layout (Required)

tsx
// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <nav>Navigation</nav>
        {children}
        <footer>Footer</footer>
      </body>
    </html>
  )
}

Nested Layouts

tsx
// app/blog/layout.tsx
export default function BlogLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="blog-layout">
      <aside>
        <h2>Blog Sidebar</h2>
      </aside>
      <main>{children}</main>
    </div>
  )
}

Metadata

tsx
// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: {
    default: 'My App',
    template: '%s | My App'  // Blog Post | My App
  },
  description: 'My awesome application',
  openGraph: {
    title: 'My App',
    description: 'My awesome application',
    images: ['/og-image.jpg']
  }
}

Dynamic Metadata

tsx
// app/blog/[slug]/page.tsx
export async function generateMetadata({
  params
}: {
  params: { slug: string }
}): Promise<Metadata> {
  const post = await getPost(params.slug)

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      images: [post.image]
    }
  }
}

Server Components

Default Behavior

tsx
// app/page.tsx - Server Component by default
async function getData() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'force-cache'  // SSG
  })
  return res.json()
}

export default async function Page() {
  const data = await getData()

  return (
    <div>
      <h1>{data.title}</h1>
    </div>
  )
}

Data Fetching Patterns

tsx
// Static Site Generation (SSG)
async function getStaticData() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'force-cache'  // Default
  })
  return res.json()
}

// Server-Side Rendering (SSR)
async function getDynamicData() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'no-store'
  })
  return res.json()
}

// Incremental Static Regeneration (ISR)
async function getRevalidatedData() {
  const res = await fetch('https://api.example.com/data', {
    next: { revalidate: 60 }  // Revalidate every 60 seconds
  })
  return res.json()
}

Parallel Data Fetching

tsx
export default async function Page() {
  // These requests happen in parallel
  const [users, posts] = await Promise.all([
    fetch('https://api.example.com/users').then(r => r.json()),
    fetch('https://api.example.com/posts').then(r => r.json())
  ])

  return (
    <div>
      <Users data={users} />
      <Posts data={posts} />
    </div>
  )
}

Client Components

Using 'use client'

tsx
// app/components/Counter.tsx
'use client'

import { useState } from 'react'

export function Counter() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Increment
      </button>
    </div>
  )
}

Client Component Best Practices

tsx
// ❌ Don't make the entire page a client component
'use client'

export default function Page() {
  const [state, setState] = useState()
  return <div>...</div>
}

// ✅ Extract interactive parts into client components
import { InteractiveWidget } from './InteractiveWidget'

export default async function Page() {
  const data = await getData()

  return (
    <div>
      <h1>{data.title}</h1>
      <InteractiveWidget />
    </div>
  )
}

Loading States

Loading.tsx

tsx
// app/blog/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
      <div className="h-4 bg-gray-200 rounded w-full mb-2" />
      <div className="h-4 bg-gray-200 rounded w-5/6" />
    </div>
  )
}

Suspense Boundaries

tsx
import { Suspense } from 'react'

export default function Page() {
  return (
    <div>
      <h1>My Page</h1>

      <Suspense fallback={<div>Loading posts...</div>}>
        <Posts />
      </Suspense>

      <Suspense fallback={<div>Loading comments...</div>}>
        <Comments />
      </Suspense>
    </div>
  )
}

async function Posts() {
  const posts = await getPosts()
  return <div>{/* Render posts */}</div>
}

async function Comments() {
  const comments = await getComments()
  return <div>{/* Render comments */}</div>
}

Error Handling

Error.tsx

tsx
// app/blog/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>
        Try again
      </button>
    </div>
  )
}

Not Found

tsx
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'

export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  const post = await getPost(params.slug)

  if (!post) {
    notFound()
  }

  return <article>{post.content}</article>
}
tsx
// app/blog/[slug]/not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h2>Post Not Found</h2>
      <p>Could not find the requested blog post.</p>
    </div>
  )
}

Dynamic Routes

Single Dynamic Segment

tsx
// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params
}: {
  params: { slug: string }
}) {
  const post = await getPost(params.slug)
  return <article>{post.content}</article>
}

// Generate static paths at build time
export async function generateStaticParams() {
  const posts = await getPosts()

  return posts.map((post) => ({
    slug: post.slug
  }))
}

Multiple Dynamic Segments

tsx
// app/blog/[category]/[slug]/page.tsx
export default async function Post({
  params
}: {
  params: { category: string; slug: string }
}) {
  const post = await getPost(params.category, params.slug)
  return <article>{post.content}</article>
}

Catch-All Routes

tsx
// app/docs/[...slug]/page.tsx
export default function Docs({
  params
}: {
  params: { slug: string[] }
}) {
  // /docs/a -> ['a']
  // /docs/a/b -> ['a', 'b']
  // /docs/a/b/c -> ['a', 'b', 'c']
  return <div>Docs: {params.slug.join('/')}</div>
}

Route Handlers (API Routes)

GET Request

tsx
// app/api/posts/route.ts
import { NextResponse } from 'next/server'

export async function GET() {
  const posts = await getPosts()
  return NextResponse.json(posts)
}

POST Request

tsx
// app/api/posts/route.ts
export async function POST(request: Request) {
  const body = await request.json()
  const post = await createPost(body)
  return NextResponse.json(post, { status: 201 })
}

Dynamic Route Handler

tsx
// app/api/posts/[id]/route.ts
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const post = await getPost(params.id)
  return NextResponse.json(post)
}

export async function DELETE(
  request: Request,
  { params }: { params: { id: string } }
) {
  await deletePost(params.id)
  return new Response(null, { status: 204 })
}

Request Helpers

tsx
export async function GET(request: Request) {
  // URL params
  const searchParams = request.nextUrl.searchParams
  const query = searchParams.get('query')

  // Headers
  const authorization = request.headers.get('authorization')

  // Cookies
  const { cookies } = await import('next/headers')
  const token = cookies().get('token')

  return NextResponse.json({ query, authorized: !!authorization })
}

Server Actions

Form Actions

tsx
// app/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  await db.post.create({
    data: { title, content }
  })

  revalidatePath('/blog')
  redirect('/blog')
}
tsx
// app/blog/new/page.tsx
import { createPost } from '@/app/actions'

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" type="text" required />
      <textarea name="content" required />
      <button type="submit">Create Post</button>
    </form>
  )
}

Progressive Enhancement

tsx
'use client'

import { useFormStatus } from 'react-dom'
import { createPost } from '@/app/actions'

function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Creating...' : 'Create Post'}
    </button>
  )
}

export function NewPostForm() {
  return (
    <form action={createPost}>
      <input name="title" type="text" required />
      <textarea name="content" required />
      <SubmitButton />
    </form>
  )
}

Parallel Routes

app/
├── @analytics/
│   └── page.tsx
├── @team/
│   └── page.tsx
└── page.tsx
tsx
// app/layout.tsx
export default function Layout({
  children,
  analytics,
  team
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div>
      {children}
      <div className="grid grid-cols-2 gap-4">
        {analytics}
        {team}
      </div>
    </div>
  )
}

Intercepting Routes

app/
├── feed/
│   └── page.tsx
├── photo/
│   └── [id]/
│       └── page.tsx
└── @modal/
    └── (.)photo/
        └── [id]/
            └── page.tsx
tsx
// app/@modal/(.)photo/[id]/page.tsx
import { Modal } from '@/components/Modal'

export default function PhotoModal({
  params
}: {
  params: { id: string }
}) {
  return (
    <Modal>
      <img src={`/photos/${params.id}.jpg`} alt="Photo" />
    </Modal>
  )
}

Middleware

tsx
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // Check authentication
  const token = request.cookies.get('token')

  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  // Add custom header
  const response = NextResponse.next()
  response.headers.set('x-custom-header', 'value')
  return response
}

export const config = {
  matcher: ['/dashboard/:path*', '/api/:path*']
}

Best Practices

1. Fetch Data Where You Need It

tsx
// ✅ Fetch in the component that needs the data
async function UserProfile({ userId }: { userId: string }) {
  const user = await getUser(userId)
  return <div>{user.name}</div>
}

// Next.js automatically deduplicates requests
export default async function Page() {
  return (
    <div>
      <UserProfile userId="1" />
      <UserProfile userId="1" />  {/* Same request, only fetched once */}
    </div>
  )
}

2. Use Loading States

tsx
// ✅ Show loading UI immediately
export default function Page() {
  return (
    <Suspense fallback={<LoadingSkeleton />}>
      <SlowComponent />
    </Suspense>
  )
}

3. Keep Client Components Small

tsx
// ✅ Small, focused client component
'use client'

export function LikeButton() {
  const [liked, setLiked] = useState(false)
  return <button onClick={() => setLiked(!liked)}>Like</button>
}

// Server component for the rest
export default async function Post({ id }: { id: string }) {
  const post = await getPost(id)

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <LikeButton />
    </article>
  )
}

Resources