Next.js App Router Tutorial 2026: Pages Router to Production
By Nayan Kyada · · 8 min read
Part of Next.js Performance
The Next.js App Router has been stable since v13.4 and is now the default in every new project. If you are migrating from the Pages Router — or coming from Remix, Nuxt, or plain React — the mental model shift is real. This guide walks through the App Router using a content site (a documentation hub with a blog section) as the running example, so every concept is grounded in a realistic file structure.
What changed from the Pages Router
The Pages Router treated every file in pages/ as a route and gave you getStaticProps, getServerSideProps, and getStaticPaths at the page level. The App Router moves routing into app/ and introduces a new file convention: page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx, and route.ts each have a fixed role.
The bigger change is the rendering model. In the Pages Router, components were always React Client Components — they ran on the server at request time for SSR, but the same code also hydrated in the browser. In the App Router, every component is a React Server Component (RSC) by default. RSCs never ship to the browser. You opt into client-side behaviour by adding 'use client' at the top of a file.
This matters for a content site because most of your UI — article bodies, navigation, sidebars — never needs interactivity. Keeping those components as RSCs means zero JS shipped for them.
File structure for a real content site
Here is the directory layout for the documentation hub we will build through this guide:
app/
layout.tsx # Root layout: html, body, global nav
page.tsx # Home page
(marketing)/
about/page.tsx
contact/page.tsx
(docs)/
layout.tsx # Docs shell: sidebar + content area
[category]/
[slug]/
page.tsx
loading.tsx
error.tsx
blog/
layout.tsx
page.tsx # Blog index
[slug]/
page.tsx
api/
revalidate/route.tsThe parenthesised folders (marketing) and (docs) are route groups. The parentheses are stripped from the URL, so (marketing)/about/page.tsx renders at /about. Route groups let you apply different layouts to different sections without the folder name appearing in the path. The docs section gets its own layout.tsx with a sidebar; the marketing pages inherit only the root layout.
Layouts and how nesting works
Each layout.tsx wraps all routes at its level and below. The root layout is the only place you render <html> and <body>. Every nested layout receives the child route as children and can add persistent UI around it.
// app/(docs)/layout.tsx
import { DocsSidebar } from '@/components/docs-sidebar'
export default function DocsLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex min-h-screen">
<DocsSidebar />
<main className="flex-1 px-8 py-12 max-w-3xl">{children}</main>
</div>
)
}DocsSidebar is a Server Component here — it can fetch its navigation data directly, without props drilling or a context provider. If it needed a toggle for mobile, you would extract that interactive piece into a 'use client' component and keep the data fetching in the Server Component wrapper.
Server Components vs Client Components
The rule I use in production: start with a Server Component. Add 'use client' only when you need browser APIs, useState, useEffect, or third-party libraries that call those.
A common pattern on a content site is a Server Component that fetches an article and passes the processed data down to a thin Client Component that handles a "copy link" button or a reading-progress bar:
// app/(docs)/[category]/[slug]/page.tsx — Server Component
import { getDoc } from '@/lib/docs'
import { CopyLinkButton } from '@/components/copy-link-button' // 'use client'
type Props = { params: Promise<{ category: string; slug: string }> }
export default async function DocPage({ params }: Props) {
const { category, slug } = await params
const doc = await getDoc(category, slug)
if (!doc) notFound()
return (
<article>
<h1 className="text-3xl font-semibold mb-6">{doc.title}</h1>
<div
className="prose prose-neutral"
dangerouslySetInnerHTML={{ __html: doc.contentHtml }}
/>
<CopyLinkButton slug={slug} />
</article>
)
}Notice params is now a Promise — Next.js 15 made dynamic route params async. You await them before use.
Data fetching patterns
In the App Router you fetch data directly inside async Server Components. No lifecycle methods, no useEffect, no getServerSideProps. fetch is extended by Next.js with caching options:
fetch(url)— cached at build time (equivalent to oldgetStaticProps)fetch(url, { cache: 'no-store' })— always dynamic, fresh per requestfetch(url, { next: { revalidate: 3600 } })— ISR, revalidate every hourfetch(url, { next: { tags: ['docs'] } })— on-demand revalidation via tag
For a database or CMS client that does not use fetch, use unstable_cache from next/cache to get the same caching semantics:
// lib/docs.ts
import { unstable_cache } from 'next/cache'
import { db } from './db'
export const getDoc = unstable_cache(
async (category: string, slug: string) => {
return db.doc.findFirst({ where: { category, slug } })
},
['doc'],
{ revalidate: 3600, tags: ['docs'] },
)For parallel data fetching, fire multiple async calls without awaiting each one sequentially:
const [doc, relatedDocs] = await Promise.all([
getDoc(category, slug),
getRelatedDocs(category, slug),
])This cuts total fetch time to the duration of the slowest call instead of their sum.
Loading and error boundaries
loading.tsx next to a page.tsx automatically wraps that segment in a <Suspense> boundary. The loading UI shows immediately while the async component resolves. For the docs section:
app/(docs)/[category]/[slug]/loading.tsx → skeleton shown while page.tsx awaits data
app/(docs)/[category]/[slug]/error.tsx → shown if page.tsx throwserror.tsx must be a Client Component because it receives an error prop and a reset function:
// app/(docs)/[category]/[slug]/error.tsx
'use client'
export default function DocError({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div className="py-16 text-center">
<p className="text-red-600 mb-4">{error.message}</p>
<button onClick={reset} className="underline">
Try again
</button>
</div>
)
}Place error.tsx files at the right level. An error boundary in (docs)/layout.tsx would catch errors from the entire docs section. One in [slug]/error.tsx catches only that page and leaves the sidebar intact.
Metadata API
The App Router replaces next/head with a typed metadata export. Every page.tsx or layout.tsx can export a metadata object or a generateMetadata async function.
For static titles and descriptions, export the object directly. For dynamic routes where the title depends on fetched content:
// app/(docs)/[category]/[slug]/page.tsx
import type { Metadata } from 'next'
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { category, slug } = await params
const doc = await getDoc(category, slug)
return {
title: doc?.title ?? 'Not found',
description: doc?.excerpt,
openGraph: {
title: doc?.title,
description: doc?.excerpt,
url: `/docs/${category}/${slug}`,
type: 'article',
},
alternates: {
canonical: `/docs/${category}/${slug}`,
},
}
}Next.js deduplicates the getDoc call between generateMetadata and the page component because the result is memoised within a single render pass — so you are not paying for two round-trips.
For the root layout.tsx, set up a metadataBase and a title template so every page gets a consistent suffix:
export const metadata: Metadata = {
metadataBase: new URL('https://docs.example.com'),
title: { default: 'Example Docs', template: '%s — Example Docs' },
description: 'Documentation for Example products.',
}Static params for dynamic routes
For fully static builds, export generateStaticParams from the page to pre-render all known paths at build time:
export async function generateStaticParams() {
const docs = await getAllDocSlugs()
return docs.map((d) => ({ category: d.category, slug: d.slug }))
}Unknown paths will 404 by default. Set export const dynamicParams = true on the segment if you want unknown paths to be rendered on demand and cached (ISR behaviour).
Static export with the App Router
If you're deploying to a plain static host — S3, GitHub Pages, a CDN with no Node runtime — set output: 'export' in next.config.ts:
// next.config.ts
import type { NextConfig } from 'next'
const config: NextConfig = {
output: 'export',
}
export default confignext build then emits a fully static out/ directory instead of a server bundle. A few hard constraints come with it:
- Every dynamic route must export
generateStaticParams— there's no server left at runtime to render an unknown path on demand, sodynamicParams = truehas no effect. - Route handlers (
route.ts) only work if they're static — no readingcookies(),headers(), orrequest.nextUrl.searchParamsat request time. next/image's default loader needs the Image Optimization API, which doesn't exist in a static export. Either configure a customloader(Cloudinary, Sanity's CDN, Cloudflare Images) or setimages: { unoptimized: true }.- Middleware, ISR (
revalidate), and Server Actions are all unavailable — everything has to be resolvable at build time.
For a documentation hub with a small, fully-known set of pages, static export is often simpler to reason about and cheaper to host than a Node deployment. For anything with on-demand content updates (a CMS-backed blog with editors publishing daily), stick with a Node or edge runtime deployment so ISR and on-demand revalidation stay available.
Where to go from here
This covers the core primitives: route groups for layout isolation, the Server/Client Component boundary, unstable_cache for non-fetch data sources, loading.tsx / error.tsx for streaming resilience, and generateMetadata for type-safe SEO. The patterns here work at any scale — the same file conventions that handle a ten-page docs site handle a thousand-page one without any architectural changes.
Frequently asked questions
01Why is params a Promise in the Next.js App Router?
Since Next.js 15, dynamic route params (and `searchParams`) are async — `{ params }: { params: Promise<{ slug: string }> }` — so the framework can start streaming a route's static shell before dynamic segments resolve. Await them before use: `const { slug } = await params`. Forgetting the `await` is the most common upgrade error when migrating an older App Router project.
02Does the Next.js App Router support static export?
Yes, via `output: 'export'` in `next.config.ts`. Every route needs `generateStaticParams` for its dynamic segments since there's no server at runtime to render unknown paths on demand, and any route using `cookies()`, `headers()`, or other request-time APIs won't work — the whole app has to be statically determinable at build time. See the static export section below for the config and its limitations.