Next.js has evolved into the de facto framework for React applications. With Next.js 15 and 16 now available, the App Router has matured into a powerful, production-ready architecture. Here's your complete guide.
Next.js App Router provides a modern file-based routing system with Server Components
Evolution Timeline
- October 2024: Next.js 15 released with Turbopack, React 19 support
- March 2025: Next.js 15.5 with typed routes
- October 2025: Next.js 16 with Cache Components
Understanding the App Router
The App Router is a file-system based router that leverages React's latest features:
app/
├── layout.tsx # Root layout
├── page.tsx # Home page (/)
├── loading.tsx # Loading UI
├── error.tsx # Error UI
├── not-found.tsx # 404 page
│
├── about/
│ └── page.tsx # /about
│
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/
│ └── page.tsx # /blog/:slug
│
└── api/
└── route.ts # API routeKey Concepts
| Concept | Description |
|---|---|
| Server Components | Default, run on server only |
| Client Components | Interactive, marked with 'use client' |
| Layouts | Shared UI that preserves state |
| Loading States | Automatic Suspense boundaries |
| Error Handling | Granular error boundaries |
Server Components by Default
Every component in the App Router is a Server Component by default:
// app/products/page.tsx
// This is a Server Component - no 'use client' directive
import { db } from '@/lib/database';
export default async function ProductsPage() {
// Direct database access - no API layer needed!
const products = await db.products.findMany({
orderBy: { createdAt: 'desc' },
});
return (
<main>
<h1>Products</h1>
<ProductGrid products={products} />
</main>
);
}Benefits of Server Components
Server Component Benefits
├── Zero JavaScript sent to client
├── Direct database/API access
├── Smaller bundle sizes
├── Better initial page load
├── SEO-friendly by default
└── Secure - secrets stay on server
Server Components render on the server, Client Components handle interactivity
Data Fetching Patterns
Pattern 1: Async Server Components
// app/users/page.tsx
async function UsersPage() {
const users = await fetch('https://api.example.com/users', {
next: { revalidate: 3600 } // Cache for 1 hour
}).then(res => res.json());
return <UserList users={users} />;
}Pattern 2: Parallel Data Fetching
// app/dashboard/page.tsx
async function DashboardPage() {
// Fetch in parallel - not waterfall!
const [user, stats, notifications] = await Promise.all([
getUser(),
getStats(),
getNotifications(),
]);
return (
<Dashboard
user={user}
stats={stats}
notifications={notifications}
/>
);
}Pattern 3: Streaming with Suspense
// app/product/[id]/page.tsx
import { Suspense } from 'react';
export default function ProductPage({ params }) {
return (
<div>
{/* Renders immediately */}
<ProductHeader id={params.id} />
{/* Streams in when ready */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews id={params.id} />
</Suspense>
{/* Streams in when ready */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations id={params.id} />
</Suspense>
</div>
);
}Next.js 15.5: Typed Routes
One of the most requested features - compile-time type safety for routes:
// Enable in next.config.js
module.exports = {
experimental: {
typedRoutes: true,
},
};// Now TypeScript catches invalid routes!
import Link from 'next/link';
// ✅ Valid - type-checked
<Link href="/about">About</Link>
<Link href="/blog/my-post">Blog Post</Link>
<Link href="/products/123">Product</Link>
// ❌ Error - route doesn't exist
<Link href="/invalid-page">Invalid</Link>
// TypeScript Error: Type '"/invalid-page"' is not assignableDynamic Route Types
// app/blog/[slug]/page.tsx generates:
type BlogSlugParams = {
slug: string;
};
// app/products/[...categories]/page.tsx generates:
type ProductCategoriesParams = {
categories: string[];
};
// Full type safety in components
export default function BlogPost({ params }: { params: BlogSlugParams }) {
return <article>{params.slug}</article>;
}Next.js 16: Cache Components
Next.js 16 introduces Cache Components - a new programming model for caching:
// app/products/page.tsx
import { cache } from 'next/cache';
// Define cached data fetching
const getProducts = cache(async () => {
return await db.products.findMany();
}, {
tags: ['products'],
revalidate: 3600, // 1 hour
});
export default async function ProductsPage() {
const products = await getProducts();
return <ProductGrid products={products} />;
}
// Revalidate from Server Action
'use server'
import { revalidateTag } from 'next/cache';
export async function addProduct(data: FormData) {
await db.products.create({ data });
revalidateTag('products'); // Invalidate cache
}Server Actions Deep Dive
Server Actions simplify data mutations:
// app/actions/contact.ts
'use server'
import { z } from 'zod';
import { db } from '@/lib/database';
const ContactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
});
export async function submitContact(formData: FormData) {
const validated = ContactSchema.parse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
});
await db.contacts.create({ data: validated });
return { success: true };
}// app/contact/page.tsx
import { submitContact } from '@/actions/contact';
export default function ContactPage() {
return (
<form action={submitContact}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<SubmitButton />
</form>
);
}
// Client Component for form state
'use client'
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button disabled={pending}>
{pending ? 'Sending...' : 'Send Message'}
</button>
);
}
Server Actions handle form submissions without separate API routes
Partial Pre-rendering (PPR)
PPR combines static and dynamic rendering in a single route:
// app/product/[id]/page.tsx
export const experimental_ppr = true;
export default async function ProductPage({ params }) {
return (
<div>
{/* Static - pre-rendered at build time */}
<Header />
<Navigation />
{/* Dynamic - rendered at request time */}
<Suspense fallback={<ProductSkeleton />}>
<ProductDetails id={params.id} />
</Suspense>
{/* Dynamic - rendered at request time */}
<Suspense fallback={<PricingSkeleton />}>
<DynamicPricing id={params.id} />
</Suspense>
{/* Static - pre-rendered */}
<Footer />
</div>
);
}How PPR Works
Request Flow with PPR:
1. Instant static shell from CDN
2. Stream dynamic content as it resolves
3. Progressive enhancement
Result:
├── TTFB: ~50ms (static shell)
├── FCP: ~100ms (visible content)
└── Complete: Varies (streaming)Route Handlers (API Routes)
// app/api/products/route.ts
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const category = searchParams.get('category');
const products = await db.products.findMany({
where: category ? { category } : undefined,
});
return NextResponse.json(products);
}
export async function POST(request: Request) {
const body = await request.json();
const product = await db.products.create({
data: body,
});
return NextResponse.json(product, { status: 201 });
}Dynamic Route Handlers
// app/api/products/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const product = await db.products.findUnique({
where: { id: params.id },
});
if (!product) {
return NextResponse.json(
{ error: 'Not found' },
{ status: 404 }
);
}
return NextResponse.json(product);
}Middleware
Handle requests before they reach your routes:
// 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('auth-token');
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
// Add headers
const response = NextResponse.next();
response.headers.set('x-custom-header', 'value');
return response;
}
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
};Turbopack: Next-Gen Bundler
Next.js 15 brings Turbopack as the default development bundler:
# Automatic in Next.js 15+
npm run dev
# Explicit in older versions
next dev --turboPerformance Comparison
| Metric | Webpack | Turbopack | Improvement |
|---|---|---|---|
| Cold Start | 12s | 1.8s | 6.7x faster |
| HMR (small) | 500ms | 50ms | 10x faster |
| HMR (large) | 2s | 200ms | 10x faster |
Best Practices
1. Colocate Data Fetching
// Good: Data fetching in the component that needs it
async function ProductCard({ id }) {
const product = await getProduct(id);
return <div>{product.name}</div>;
}
// Avoid: Prop drilling from parent
function ProductCard({ product }) {
return <div>{product.name}</div>;
}2. Use Loading States
// app/products/loading.tsx
export default function Loading() {
return (
<div className="grid grid-cols-3 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="animate-pulse h-48 bg-gray-200" />
))}
</div>
);
}3. Error Boundaries
// app/products/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={reset}>Try again</button>
</div>
);
}4. Metadata API
// app/blog/[slug]/page.tsx
import { Metadata } from 'next';
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
images: [post.image],
},
};
}Migration Checklist
Migrating to App Router:
□ Move pages to app/ directory
□ Convert _app.tsx to layout.tsx
□ Convert _document.tsx to layout.tsx
□ Update data fetching (getServerSideProps → async components)
□ Update API routes to route handlers
□ Add 'use client' to interactive components
□ Update metadata approach
□ Test all routesResources
Building a Next.js application? Contact CODERCOPS for expert Next.js development services.
Comments