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
| Feature | Pages Router | App Router |
|---|---|---|
| Directory | pages/ | app/ |
| Routing file | index.tsx | page.tsx |
| Layouts | _app.tsx (global only) | layout.tsx (nested) |
| Data fetching | getServerSideProps, getStaticProps | async Server Components + fetch |
| Default rendering | Client Components | Server Components |
| Loading states | Manual implementation | loading.tsx (built-in) |
| Error handling | _error.tsx | error.tsx (per-route) |
| Mutations | API routes + client fetch | Server Actions |
| Metadata/SEO | next/head | metadata 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 When | Use Client Components When |
|---|---|
| Fetching data | Using useState, useEffect, useRef |
| Accessing backend resources directly | Using browser APIs (window, localStorage) |
| Keeping secrets on the server | Adding event listeners (onClick, onChange) |
| Rendering static or rarely changing UI | Using React context providers |
| Reducing client JavaScript bundle | Using 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
| File | Purpose |
|---|---|
page.tsx | UI for a route — makes the route accessible |
layout.tsx | Shared UI for a segment and its children |
loading.tsx | Loading UI (auto-wrapped in Suspense) |
error.tsx | Error UI (must be Client Component) |
not-found.tsx | 404 UI for this segment |
route.ts | API endpoint (GET, POST, etc.) |
template.tsx | Like layout but remounts on navigation |
default.tsx | Fallback for parallel routes |
| Command | Purpose |
|---|---|
npx create-next-app@latest | Create new project |
npm run dev | Start dev server |
npm run build | Production build |
npm start | Start production server |
next lint | Run ESLint |