Web Development · Next.js
Next.js 15 & 16: The Complete App Router Guide
A working reference for the Next.js App Router: Server Components, data fetching patterns, Cache Components, typed routes, and Partial Pre-rendering, with real code.
Anurag Verma
17 min read · Updated Sep 21, 2026
Sponsored
Next.js has evolved into the de facto framework for React applications. With Next.js 15 and 16 now available, and full React 19 support across both, 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 (opt-in for dev), React 19 support
- August 2025: Next.js 15.5 with stable typed routes and Turbopack production builds
- October 2025: Next.js 16 with Cache Components and Turbopack as the default bundler
Each of these releases addressed a specific pain point teams ran into with the App Router in production. Turbopack in Next.js 15 targeted the slow cold starts and HMR lag that made large App Router projects frustrating to work on. Typed routes in 15.5 caught a class of bugs (<Link>s pointing at routes that no longer exist) that used to only surface at runtime. Cache Components in Next.js 16 replaced the implicit, hard-to-reason-about caching behavior of earlier versions with an explicit model where caching is opt-in per function rather than inferred from how a route is structured.
Understanding the App Router
The App Router is a file-system based router that uses React’s latest features. If you’ve worked with the older Pages Router (pages/), the mental model shift is significant: routing, layouts, loading states, and error boundaries are now all colocated as files inside the route segment they apply to, instead of living in separate top-level files like _app.tsx and _document.tsx. We compared this approach against other frameworks’ routing models in our Next.js vs Astro vs Remix comparison, if you’re deciding whether the App Router is the right fit before committing to it.
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 route
Key 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: it renders on the server, its code never ships to the browser, and it can reach a database or a secret-holding API directly, without a separate API route in between.
// 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>
);
}
Route Groups and Parallel Routes
The file-system router supports two conventions that go beyond simple nested folders: route groups and parallel routes.
A route group is a folder wrapped in parentheses, like (marketing) or (shop). It lets you organize routes and share a layout across a section of the site without that folder name showing up in the URL:
app/
├── (marketing)/
│ ├── layout.tsx # Layout for marketing pages only
│ ├── page.tsx # /
│ └── pricing/
│ └── page.tsx # /pricing
└── (shop)/
├── layout.tsx # Different layout for shop pages
└── cart/
└── page.tsx # /cart
Both (marketing) and (shop) disappear from the URL, but each gets its own root layout, which is the standard way to give a marketing site and an app dashboard different shells inside one Next.js project.
A parallel route is a folder prefixed with @, and it renders as an independent, simultaneously-loaded slot inside the same layout:
app/
├── layout.tsx
├── @dashboard/
│ └── page.tsx
├── @modal/
│ ├── default.tsx # Rendered when no modal route matches
│ └── settings/
│ └── page.tsx # Renders in @modal when /settings is visited
└── page.tsx
// app/layout.tsx
export default function Layout({
children,
dashboard,
modal,
}: {
children: React.ReactNode;
dashboard: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<>
{children}
{dashboard}
{modal}
</>
);
}
Each slot is passed to the layout as a prop named after its folder. This is the pattern behind dashboards that render a sidebar and a main panel independently, each with its own loading and error states.
Intercepting Routes
Intercepting routes combine with parallel routes to build modal-over-page UIs where the modal is still a real, shareable URL. The folder prefix controls how many segments up the interception reaches: (.) intercepts the same level, (..) one level up, (..)(..) two levels up, and (...) from the root.
app/
├── feed/
│ ├── page.tsx # /feed
│ └── (..)photo/
│ └── [id]/
│ └── page.tsx # Intercepted modal for /photo/:id
└── photo/
└── [id]/
└── page.tsx # Full page for /photo/:id (direct visit or refresh)
Navigating to a photo from the feed renders it as a modal over the feed using the intercepted route. Refreshing the page or sharing the link renders the full photo/[id]/page.tsx instead, so the URL always resolves to real content whether or not the interception fires. This is the same mechanism Instagram-style photo grids use, and it works entirely with App Router file conventions, no client-side routing library needed. Our guide on Server Components in the App Router covers how these route segments compose with the server/client component boundary.
Controlling Static Generation
generateStaticParams pre-renders a dynamic route’s pages at build time instead of on each request:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({ slug: post.slug }));
}
// Reject requests for params not returned above
export const dynamicParams = false;
With dynamicParams left at its default of true, a request for a slug not in the list still renders on demand and gets cached afterward. Setting it to false makes Next.js return a 404 for anything outside the pre-rendered set, which is the right choice when the list of valid slugs is closed and known at build time.
template.tsx vs. layout.tsx
Both wrap child routes, but they behave differently across navigation. A layout.tsx persists: navigating between two routes that share a layout does not remount it, so any local state in the layout survives. A template.tsx remounts on every navigation, even between routes that share the same template:
// app/dashboard/template.tsx
'use client'
export default function Template({ children }: { children: React.ReactNode }) {
// This state resets on every navigation into or within /dashboard
return <div className="page-transition">{children}</div>;
}
Use a template when you need a fresh mount per navigation, such as replaying an enter animation, resetting a form, or re-triggering a useEffect. Use a layout for anything that should persist, like a sidebar’s scroll position or an open state.
Next.js 15.5: Typed Routes
One of the most requested features - compile-time type safety for routes. Before typed routes, a typo in an href was a runtime 404 nobody caught until a user hit it; TypeScript now generates route types directly from the app/ directory structure, so an invalid path fails the build instead:
// 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 assignable
Dynamic 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>;
}
Breaking Change: Async Dynamic APIs
The change that breaks the most Next.js 14 code during an upgrade to 15 or 16 isn’t Turbopack or Cache Components. It’s that params, searchParams, cookies(), headers(), and draftMode() all became asynchronous. In Next.js 14, params was a plain object you could read synchronously. Starting in Next.js 15, it’s a Promise you have to await:
// app/blog/[slug]/page.tsx — Next.js 15+
type Params = Promise<{ slug: string }>;
export default async function BlogPost({ params }: { params: Params }) {
const { slug } = await params;
return <article>{slug}</article>;
}
// Client Components can't use await — unwrap with React's use() instead
'use client'
import { use } from 'react';
export default function BlogPostClient({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = use(params);
return <article>{slug}</article>;
}
Vercel ships a codemod (npx @next/codemod@latest next-async-request-api .) that rewrites most call sites automatically, but it’s worth reviewing anywhere your app reads params or searchParams synchronously before you upgrade, including the Route Handler and Server Component examples elsewhere in this guide, several of which use the pre-15 synchronous style and need the same await treatment on Next.js 15+.
Next.js 16: Cache Components
Next.js 16 introduces Cache Components - a new programming model built around the "use cache" directive. Unlike the implicit caching of earlier App Router versions, nothing is cached unless you say so:
// app/products/page.tsx
import { cacheLife, cacheTag } from 'next/cache';
// Mark this function's result as cacheable
async function getProducts() {
'use cache';
cacheLife('hours'); // built-in cache profile
cacheTag('products'); // tag for targeted invalidation
return await db.products.findMany();
}
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 anything tagged 'products'
}
Everything without a "use cache" directive is dynamic by default now, rendered fresh on every request. That’s the opposite of the Next.js 14 default (cache unless opted out), so upgrading an existing app means auditing which routes actually need caching and adding the directive explicitly rather than assuming Next.js will cache for you. For the full mechanics of how this pairs with Turbopack and the React Compiler, see our deeper dive on Next.js 16.
Server Actions Deep Dive
Server Actions simplify data mutations by letting a form submit directly to a server-side function, no separate API route, no client-side fetch call, and no manually wiring up a JSON body. Validation still belongs on the server regardless, since a Server Action is reachable the same way any API endpoint is:
// 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. The parts of the page that don’t depend on the specific request, a header, navigation, footer, get pre-rendered at build time and served instantly from the CDN. The parts that do, personalized pricing, live inventory, a user’s own data, render at request time and stream in behind a Suspense boundary. The visitor sees a complete, fast shell immediately, and the dynamic content fills in as it resolves, rather than the whole page waiting on the slowest piece:
// 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, useful for auth checks, redirects, and header manipulation at the edge, similar to what a dedicated edge function would handle outside the Next.js runtime:
// 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 shipped Turbopack as a stable option for local development, but it’s opt-in — webpack is still the default bundler unless you pass the flag. Next.js 16 flips that: Turbopack becomes the default for both next dev and production builds.
# Next.js 15: opt in explicitly
next dev --turbopack
# Next.js 16+: Turbopack is the default
npm run dev
# Fall back to webpack on 16+ if you need to
next dev --webpack
Performance 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
App Router migrations tend to fail in predictable ways. The most common: marking a component 'use client' just to use a hook like useState, when only the small interactive piece (a button, a form) actually needs it, and the rest of the tree could have stayed on the server. Another is fetching sequentially with multiple await calls in a row instead of Promise.all, which turns three 200ms requests into a 600ms waterfall. A third is putting cookies() or headers() calls at the top of a page component “just in case,” which under the pre-Cache-Components model would silently force the entire route to render dynamically instead of being cached.
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. Generate Static Params for Known Routes
A gap in most App Router tutorials: if a dynamic route’s possible values are knowable at build time (blog slugs, product IDs from a catalog that doesn’t change every minute), export generateStaticParams so Next.js pre-renders those pages instead of rendering every request dynamically:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return <article>{post.title}</article>;
}
Routes not returned by generateStaticParams still work; they render on demand the first time they’re requested, then get cached. This is the App Router equivalent of getStaticPaths from the Pages Router, and skipping it is a common reason teams see unnecessarily high server render counts after migrating: every blog post request hits the server fresh instead of being served from a pre-rendered page.
5. 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],
},
};
}
Choosing what to adopt first
Not every project needs every feature in this guide on day one. If you’re migrating an existing Pages Router app, get the routing and data-fetching conversion working first, that’s the mechanical, mostly-safe part. Typed routes are close to a free upgrade once you’re on the App Router; turn them on early since they only get more valuable as the route tree grows. Cache Components and Partial Pre-rendering are worth reaching for once you have a specific page with a real caching or latency problem to solve, not as a default for every route from the start. Retrofitting them onto a page that didn’t need them adds a caching model to reason about for no measurable benefit.
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 routes
Resources
Building a Next.js application? Contact CODERCOPS for expert Next.js development services, or see what to look for when hiring a Next.js developer if you’re building the team in-house.
Frequently asked questions
- What's the difference between Next.js 15 and Next.js 16?
- Next.js 15 (October 2024) introduced Turbopack as an opt-in dev bundler and React 19 support, with typed routes becoming stable in the 15.5 update in August 2025. Next.js 16 (October 2025) made Turbopack the default bundler for both dev and production, and introduced Cache Components, a new opt-in caching model built on the 'use cache' directive.
- Do I need to rewrite my code to upgrade from the Pages Router to the App Router?
- Yes, meaningfully. You move pages into the app/ directory, convert _app.tsx and _document.tsx into layout.tsx, replace getServerSideProps with async Server Components, convert API routes to Route Handlers, and add 'use client' to any component that needs interactivity or browser APIs.
- Why did my Next.js app break after upgrading to 15?
- The most common cause is dynamic APIs becoming asynchronous. params, searchParams, cookies(), headers(), and draftMode() all return Promises now instead of resolving synchronously, so code written for Next.js 14 needs an await (or React's use() in Client Components) added everywhere those values are read.
- Is Turbopack ready for production in Next.js 15?
- Turbopack was stable for local development in Next.js 15 behind the --turbopack flag, but webpack remained the default for production builds. Next.js 16 is where Turbopack became the default for both development and production.
- What are Server Components actually good for?
- Server Components run only on the server, so they ship zero JavaScript to the client, can query a database or call internal APIs directly without an extra API layer, and keep secrets like connection strings out of the browser bundle. Interactive UI still needs a Client Component marked with 'use client'.
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored