Next.js App Router tutorial guide 2026: from Pages Router to production
May 12, 2026 · 7 min read
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).
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.
Related posts
All posts →Sanity CMS + Next.js App Router: a practical getting started guide
May 11, 2026 · 6 min read
A step-by-step sanity cms nextjs tutorial for developers setting up schemas, GROQ queries, and Sanity Studio with the App Router for the first time.
How I wire Sanity webhooks to Next.js ISR revalidation with HMAC verification
May 11, 2026 · 6 min read
Step-by-step guide to connecting Sanity webhooks to Next.js route handlers for on-demand ISR revalidation, with HMAC signing and error handling.