Migrating to Next.js App Router: A Real-World Guide

Having recently led multiple enterprise-level migrations from Next.js Pages Router to App Router, I've gained valuable insights into making this transition smooth and effective. Here's a detailed look at the strategies and patterns that have proven most successful.

Understanding the New Mental Model

The App Router introduces a fundamentally different way of thinking about routing and data fetching. Here's how I structure a typical application:

app/
  ├── layout.tsx          # Root layout
  ├── page.tsx           # Home page
  ├── (auth)/            # Auth group
  │   ├── login/
  │   │   └── page.tsx
  │   └── register/
  │       └── page.tsx
  └── dashboard/
      ├── layout.tsx     # Dashboard layout
      ├── page.tsx       # Dashboard home
      └── [id]/          # Dynamic route
          └── page.tsx

Server Components and Client Components

One of the biggest paradigm shifts is understanding when to use Server vs. Client Components:

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { DashboardMetrics } from './components/DashboardMetrics';
import { ClientSideChart } from './components/ClientSideChart';

// Server Component by default
export default async function DashboardPage() {
  const metrics = await fetchDashboardMetrics();
  
  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold">Dashboard</h1>
      
      <Suspense fallback={<MetricsLoader />}>
        <DashboardMetrics data={metrics} />
      </Suspense>
      
      {/* Client Component for interactive features */}
      <ClientSideChart />
    </div>
  );
}

// components/ClientSideChart.tsx
'use client';

import { useState } from 'react';
import { LineChart } from '@/components/charts';

export function ClientSideChart() {
  const [timeRange, setTimeRange] = useState('1w');
  
  return (
    <div>
      <select value={timeRange} onChange={(e) => setTimeRange(e.target.value)}>
        <option value="1w">Last Week</option>
        <option value="1m">Last Month</option>
        <option value="1y">Last Year</option>
      </select>
      <LineChart timeRange={timeRange} />
    </div>
  );
}

Data Fetching Patterns

The App Router introduces powerful new data fetching capabilities:

// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';

async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { revalidate: 3600 }, // Revalidate every hour
  });
  
  if (!res.ok) {
    // This will trigger the closest error.tsx Error Boundary
    throw new Error('Failed to fetch product');
  }
  
  return res.json();
}

export default async function ProductPage({
  params: { id },
}: {
  params: { id: string };
}) {
  const product = await getProduct(id);
  
  if (!product) {
    notFound();
  }
  
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton productId={id} />
    </div>
  );
}

Route Handlers and API Integration

The new Route Handlers provide a more flexible way to build APIs:

// app/api/products/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';

const productSchema = z.object({
  name: z.string().min(1),
  price: z.number().positive(),
  description: z.string(),
});

export async function POST(request: Request) {
  try {
    const json = await request.json();
    const product = productSchema.parse(json);
    
    const newProduct = await db.product.create({
      data: product,
    });
    
    return NextResponse.json(newProduct, { status: 201 });
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json({ errors: error.errors }, { status: 400 });
    }
    
    return NextResponse.json(
      { error: 'Internal Server Error' },
      { status: 500 }
    );
  }
}

Handling Loading and Error States

The App Router provides a more elegant way to handle loading and error states:

// app/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="animate-pulse">
      <div className="h-8 w-64 bg-gray-200 rounded mb-4" />
      <div className="grid grid-cols-3 gap-4">
        {[1, 2, 3].map((i) => (
          <div key={i} className="h-40 bg-gray-200 rounded" />
        ))}
      </div>
    </div>
  );
}

// app/dashboard/error.tsx
'use client';

import { useEffect } from 'react';

export default function DashboardError({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  useEffect(() => {
    console.error(error);
  }, [error]);
  
  return (
    <div className="p-6">
      <h2 className="text-xl font-bold text-red-600">Something went wrong!</h2>
      <button
        onClick={reset}
        className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
      >
        Try again
      </button>
    </div>
  );
}

Middleware and Authentication

Implementing authentication with middleware becomes more straightforward:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';

export async function middleware(request: NextRequest) {
  const token = await getToken({ req: request });
  
  if (!token) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('from', request.nextUrl.pathname);
    return NextResponse.redirect(loginUrl);
  }
  
  return NextResponse.next();
}

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

The migration to App Router represents a significant evolution in Next.js development. While the transition requires careful planning and understanding of new concepts, the benefits in terms of performance, developer experience, and code organization make it well worth the effort.