App Router vs Pages Router: What Changed

Next.js 14 solidified the App Router as the recommended way to build Next.js applications. If you are coming from the Pages Router, the mental model is fundamentally different. The App Router introduces React Server Components, nested layouts, streaming, and a completely new file convention system.

Key Differences at a Glance

FeaturePages RouterApp Router
Directorypages/app/
Routing fileindex.tsxpage.tsx
Layouts_app.tsx (global only)layout.tsx (nested)
Data fetchinggetServerSideProps, getStaticPropsasync Server Components + fetch
Default renderingClient ComponentsServer Components
Loading statesManual implementationloading.tsx (built-in)
Error handling_error.tsxerror.tsx (per-route)
MutationsAPI routes + client fetchServer Actions
Metadata/SEOnext/headmetadata export / generateMetadata

File-Based Routing in the App Router

The App Router uses a directory-based routing system where each folder represents a route segment. Special files within each folder define the UI for that segment:

app/
├── layout.tsx          # Root layout (wraps ALL pages)
├── page.tsx            # Home page (/)
├── loading.tsx         # Loading UI for /
├── error.tsx           # Error UI for /
├── not-found.tsx       # 404 page
├── globals.css
│
├── about/
│   └── page.tsx        # /about
│
├── blog/
│   ├── layout.tsx      # Blog layout (wraps all /blog/* pages)
│   ├── page.tsx        # /blog (blog listing)
│   ├── loading.tsx     # Loading UI for /blog
│   └── [slug]/
│       ├── page.tsx    # /blog/my-post (dynamic)
│       └── not-found.tsx
│
├── dashboard/
│   ├── layout.tsx      # Dashboard layout with sidebar
│   ├── page.tsx        # /dashboard
│   ├── settings/
│   │   └── page.tsx    # /dashboard/settings
│   └── analytics/
│       └── page.tsx    # /dashboard/analytics
│
└── api/
    └── users/
        └── route.ts    # API: /api/users

Root Layout (Required)

// app/layout.tsx — the root layout
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: {
    default: "My App",
    template: "%s | My App",
  },
  description: "Built with Next.js 14 App Router",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <nav>{/* Shared navigation */}</nav>
        <main>{children}</main>
        <footer>{/* Shared footer */}</footer>
      </body>
    </html>
  );
}

Loading and Error States

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

// app/blog/error.tsx — must be a Client Component
"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>
  );
}

// app/not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h2>404 — Page Not Found</h2>
      <p>The page you are looking for does not exist.</p>
    </div>
  );
}

Server Components vs Client Components

In the App Router, all components are Server Components by default. They run only on the server, which means zero JavaScript sent to the browser for that component. Use the "use client" directive only when you need browser interactivity.

When to Use Server vs Client Components

Use Server Components WhenUse Client Components When
Fetching dataUsing useState, useEffect, useRef
Accessing backend resources directlyUsing browser APIs (window, localStorage)
Keeping secrets on the serverAdding event listeners (onClick, onChange)
Rendering static or rarely changing UIUsing React context providers
Reducing client JavaScript bundleUsing third-party client libraries
// Server Component (default — no directive needed)
// app/blog/page.tsx
import { prisma } from "@/lib/prisma";

export default async function BlogPage() {
  // This runs ONLY on the server — DB queries are safe here
  const posts = await prisma.post.findMany({
    orderBy: { createdAt: "desc" },
    take: 10,
  });

  return (
    <div>
      <h1>Blog</h1>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  );
}

// Client Component — needs "use client" directive
// app/blog/SearchBar.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

export default function SearchBar() {
  const [query, setQuery] = useState("");
  const router = useRouter();

  const handleSearch = (e: React.FormEvent) => {
    e.preventDefault();
    router.push(`/blog?search=${encodeURIComponent(query)}`);
  };

  return (
    <form onSubmit={handleSearch}>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search posts..."
      />
      <button type="submit">Search</button>
    </form>
  );
}

Data Fetching with Caching and Revalidation

// Cached by default (equivalent to getStaticProps)
async function getProducts() {
  const res = await fetch("https://api.example.com/products");
  return res.json();
}

// Revalidate every 60 seconds (ISR)
async function getProducts() {
  const res = await fetch("https://api.example.com/products", {
    next: { revalidate: 60 },
  });
  return res.json();
}

// No caching (equivalent to getServerSideProps)
async function getProducts() {
  const res = await fetch("https://api.example.com/products", {
    cache: "no-store",
  });
  return res.json();
}

// Tag-based revalidation
async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { tags: [`product-${id}`] },
  });
  return res.json();
}

// Revalidate by tag from a Server Action
import { revalidateTag } from "next/cache";

export async function updateProduct(id: string, data: FormData) {
  "use server";
  await db.product.update({ where: { id }, data: { ... } });
  revalidateTag(`product-${id}`);
}

Server Actions for Mutations

// app/blog/new/page.tsx — Server Action for form handling
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";

export default function NewPostPage() {
  async function createPost(formData: FormData) {
    "use server";

    const title = formData.get("title") as string;
    const content = formData.get("content") as string;

    await prisma.post.create({
      data: {
        title,
        content,
        slug: title.toLowerCase().replace(/s+/g, "-"),
      },
    });

    revalidatePath("/blog");
    redirect("/blog");
  }

  return (
    <form action={createPost}>
      <label htmlFor="title">Title</label>
      <input type="text" id="title" name="title" required />

      <label htmlFor="content">Content</label>
      <textarea id="content" name="content" required />

      <button type="submit">Create Post</button>
    </form>
  );
}

Middleware

// middleware.ts (in project root)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Authentication check
  const token = request.cookies.get("session-token")?.value;
  if (pathname.startsWith("/dashboard") && !token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  // Geolocation-based redirect
  const country = request.geo?.country || "US";
  if (pathname === "/" && country === "DE") {
    return NextResponse.redirect(new URL("/de", request.url));
  }

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

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

API Routes in App Router

// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const page = parseInt(searchParams.get("page") || "1");
  const limit = parseInt(searchParams.get("limit") || "10");

  const users = await prisma.user.findMany({
    skip: (page - 1) * limit,
    take: limit,
  });

  return NextResponse.json({ users, page, limit });
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const { name, email } = body;

  const user = await prisma.user.create({
    data: { name, email },
  });

  return NextResponse.json(user, { status: 201 });
}

// app/api/users/[id]/route.ts — dynamic API route
export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const user = await prisma.user.findUnique({
    where: { id: parseInt(params.id) },
  });

  if (!user) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }

  return NextResponse.json(user);
}

Dynamic Routes and Catch-All Segments

// app/blog/[slug]/page.tsx — dynamic route
import { notFound } from "next/navigation";

interface Props {
  params: { slug: string };
}

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

  if (!post) {
    notFound(); // Renders the closest not-found.tsx
  }

  return <article><h1>{post.title}</h1></article>;
}

// Generate static paths at build time
export async function generateStaticParams() {
  const posts = await getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}

// app/docs/[...slug]/page.tsx — catch-all route
// Matches /docs/a, /docs/a/b, /docs/a/b/c
interface DocsProps {
  params: { slug: string[] };
}

export default function DocsPage({ params }: DocsProps) {
  const path = params.slug.join("/"); // "a/b/c"
  return <div>Docs: {path}</div>;
}

Parallel Routes and Intercepting Routes

# Parallel routes use @folder convention
app/
├── layout.tsx
├── page.tsx
├── @analytics/
│   └── page.tsx       # Renders alongside main page
├── @sidebar/
│   └── page.tsx       # Renders alongside main page
│
# Intercepting routes use (.) (..) (...) convention
├── feed/
│   └── page.tsx
├── @modal/
│   └── (.)photo/[id]/
│       └── page.tsx   # Intercepts /photo/[id] and shows as modal
└── photo/
    └── [id]/
        └── page.tsx   # Full page (direct navigation)
// app/layout.tsx — consuming parallel routes
export default function Layout({
  children,
  analytics,
  sidebar,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  sidebar: React.ReactNode;
}) {
  return (
    <div className="flex">
      <aside>{sidebar}</aside>
      <main>{children}</main>
      <aside>{analytics}</aside>
    </div>
  );
}

Metadata API for SEO

// Static metadata
export const metadata: Metadata = {
  title: "My Blog",
  description: "A blog about web development",
  openGraph: {
    title: "My Blog",
    description: "A blog about web development",
    images: ["/og-image.png"],
  },
};

// Dynamic metadata
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await getPost(params.slug);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
      type: "article",
      publishedTime: post.publishedAt,
    },
    twitter: {
      card: "summary_large_image",
      title: post.title,
      description: post.excerpt,
    },
  };
}

Image, Font, and Script Optimization

// next/image — automatic optimization
import Image from "next/image";

<Image
  src="/hero.jpg"
  alt="Hero image"
  width={1200}
  height={600}
  priority       // Load above-the-fold images immediately
  placeholder="blur"
  blurDataURL="data:image/png;base64,..."
/>

// next/font — zero layout shift fonts
import { Inter, Roboto_Mono } from "next/font/google";

const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
});

// next/script — optimized script loading
import Script from "next/script";

<Script
  src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
  strategy="afterInteractive" // lazyOnload | beforeInteractive | afterInteractive
/>

Authentication with NextAuth.js v5

npm install next-auth@beta
// auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    GitHub({
      clientId: process.env.GITHUB_ID!,
      clientSecret: process.env.GITHUB_SECRET!,
    }),
    Credentials({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      authorize: async (credentials) => {
        const user = await getUserByEmail(credentials.email as string);
        if (!user || !await verifyPassword(credentials.password as string, user.password)) {
          return null;
        }
        return user;
      },
    }),
  ],
  callbacks: {
    authorized: async ({ auth }) => {
      return !!auth;
    },
  },
});

// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

Common Errors and Fixes

Hydration Mismatch

Problem: Error: Text content does not match server-rendered HTML.

Cause: Server and client render different content (e.g., using Date.now() or window).

Solution:

"use client";
import { useState, useEffect } from "react";

export default function ClientDate() {
  const [date, setDate] = useState<string>("");

  useEffect(() => {
    setDate(new Date().toLocaleDateString());
  }, []);

  return <span>{date}</span>; // Only renders on client
}

use client Boundary Issues

Problem: Importing a Server Component into a Client Component.

Cause: Client Components cannot import Server Components directly.

Solution: Pass Server Components as children props:

// WRONG
"use client";
import ServerComponent from "./ServerComponent"; // Error!

// CORRECT — pass as children
// page.tsx (Server Component)
import ClientWrapper from "./ClientWrapper";
import ServerComponent from "./ServerComponent";

export default function Page() {
  return (
    <ClientWrapper>
      <ServerComponent />  {/* Passed as children */}
    </ClientWrapper>
  );
}

Deploying Next.js

# Deploy to Vercel (easiest)
npm install -g vercel
vercel

# Self-hosted with Node.js
npm run build
npm start  # Starts production server on port 3000

# Docker deployment
# Dockerfile
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

Quick Reference Cheat Sheet

FilePurpose
page.tsxUI for a route — makes the route accessible
layout.tsxShared UI for a segment and its children
loading.tsxLoading UI (auto-wrapped in Suspense)
error.tsxError UI (must be Client Component)
not-found.tsx404 UI for this segment
route.tsAPI endpoint (GET, POST, etc.)
template.tsxLike layout but remounts on navigation
default.tsxFallback for parallel routes
CommandPurpose
npx create-next-app@latestCreate new project
npm run devStart dev server
npm run buildProduction build
npm startStart production server
next lintRun ESLint