Next.js Routing
Targeting Next.js 16 (App Router)
Syntax
File-Based Routes
app/
├── page.tsx → /
├── about/page.tsx → /about
├── blog/page.tsx → /blog
├── blog/[slug]/page.tsx → /blog/:slug
└── docs/[...slug]/page.tsx → /docs/*Page Component
// app/about/page.tsx
export default function AboutPage() {
return <h1>About</h1>
}
// pages must be default exportsLayout
// app/layout.tsx — wraps all pages
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}Nested Layout
// app/dashboard/layout.tsx
// only wraps /dashboard/* routes
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="dashboard">
<Sidebar />
<main>{children}</main>
</div>
)
}Loading UI
// app/dashboard/loading.tsx
// shown while page.tsx streams in
export default function Loading() {
return <p>Loading...</p>
}
// wraps page in Suspense automaticallyError Handling
// app/dashboard/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div>
<p>{error.message}</p>
<button onClick={reset}>Retry</button>
</div>
)
}Not Found
// app/not-found.tsx — custom 404
export default function NotFound() {
return <h1>Page not found</h1>
}
// trigger from any server component
import { notFound } from 'next/navigation'
if (!data) notFound()Route Groups
app/
├── (marketing)/
│ ├── layout.tsx ← shared marketing layout
│ ├── page.tsx → /
│ └── about/page.tsx → /about
├── (app)/
│ ├── layout.tsx ← shared app layout
│ └── dashboard/page.tsx → /dashboardBeginner Patterns
Dynamic Params
// app/blog/[slug]/page.tsx
interface Props {
params: Promise<{ slug: string }>
}
export default async function Post({ params }: Props) {
const { slug } = await params
return <h1>{slug}</h1>
}
// /blog/hello-world → slug = 'hello-world'Catch-All Params
// app/docs/[...slug]/page.tsx
interface Props {
params: Promise<{ slug: string[] }>
}
export default async function Docs({ params }: Props) {
const { slug } = await params
return <p>{slug.join(' / ')}</p>
}
// /docs/a/b/c → slug = ['a', 'b', 'c']Link Component
import Link from 'next/link'
<Link href="/about">About</Link>
// dynamic route
<Link href={`/blog/${post.slug}`}>
{post.title}
</Link>
// prefetches by default when visible in viewportuseRouter
'use client'
import { useRouter } from 'next/navigation'
function LogoutButton() {
const router = useRouter()
return (
<button onClick={() => {
logout()
router.push('/login') // navigate
router.replace('/login') // no back button
router.refresh() // re-fetch server components
}}>
Log out
</button>
)
}usePathname
'use client'
import { usePathname } from 'next/navigation'
function Nav() {
const pathname = usePathname() // '/dashboard'
return (
<nav>
<Link
href="/dashboard"
className={pathname === '/dashboard' ? 'active' : ''}
>
Dashboard
</Link>
</nav>
)
}Search Params
// server component — params from props
interface Props {
searchParams: Promise<{ q?: string }>
}
export default async function Search({ searchParams }: Props) {
const { q } = await searchParams
return <p>Searching: {q}</p>
}
// /search?q=hello → q = 'hello'Intermediate Patterns
useSearchParams
'use client'
import { useSearchParams } from 'next/navigation'
function Filters() {
const searchParams = useSearchParams()
const sort = searchParams.get('sort') // 'price'
return <p>Sorting by {sort}</p>
}
// /products?sort=priceUpdate Search Params
'use client'
import { useRouter, useSearchParams, usePathname }
from 'next/navigation'
function SortSelect() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
function setSort(value: string) {
const params = new URLSearchParams(searchParams)
params.set('sort', value)
router.push(`${pathname}?${params}`)
}
return <select onChange={(e) => setSort(e.target.value)}>
<option value="price">Price</option>
<option value="name">Name</option>
</select>
}Parallel Routes
app/dashboard/
├── layout.tsx
├── page.tsx
├── @analytics/page.tsx
└── @team/page.tsx// app/dashboard/layout.tsx
export default function Layout({
children, analytics, team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return <>{children}{analytics}{team}</>
}Intercepting Routes
app/
├── feed/page.tsx
├── photo/[id]/page.tsx ← full page
└── feed/@modal/(.)photo/[id]/page.tsx ← modal overlayMetadata
// static
export const metadata = {
title: 'My App',
description: 'A cool app',
}
// dynamic
export async function generateMetadata(
{ params }: Props
) {
const { slug } = await params
const post = await getPost(slug)
return { title: post.title }
}Proxy (was Middleware)
// proxy.ts (project root — renamed from middleware.ts)
import { NextResponse } from 'next/server'
export function proxy(req: Request) {
const cookie = req.headers.get('cookie') ?? ''
if (!cookie.includes('token=')) {
return NextResponse.redirect(new URL('/login', req.url))
}
return NextResponse.next()
}
export const config = {
matcher: '/dashboard/:path*',
}
// runtime is always nodejs — edge is NOT supported in proxyAdvanced Patterns
generateStaticParams
// app/blog/[slug]/page.tsx
// pre-render these at build time
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((p) => ({ slug: p.slug }))
}
// generates /blog/first, /blog/second, etc.Route Handlers
// app/api/users/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
const users = await db.user.findMany()
return NextResponse.json(users)
}
export async function POST(req: Request) {
const body = await req.json()
const user = await db.user.create({ data: body })
return NextResponse.json(user, { status: 201 })
}Dynamic Route Handler
// app/api/users/[id]/route.ts
interface Ctx {
params: Promise<{ id: string }>
}
export async function GET(_req: Request, ctx: Ctx) {
const { id } = await ctx.params
const user = await db.user.findUnique({
where: { id },
})
if (!user) {
return NextResponse.json(
{ error: 'Not found' },
{ status: 404 }
)
}
return NextResponse.json(user)
}Redirect
import { redirect, permanentRedirect }
from 'next/navigation'
// in a server component or action
async function checkAuth() {
const user = await getUser()
if (!user) redirect('/login') // 307
if (user.banned) permanentRedirect('/banned') // 308
}
// in next.config
// redirects: async () => [
// { source: '/old', destination: '/new', permanent: true },
// ]Rewrite & Headers
// next.config.ts
const config = {
async rewrites() {
return [
{ source: '/api/:path*',
destination: 'https://api.example.com/:path*' },
]
},
async headers() {
return [{
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Origin', value: '*' },
],
}]
},
}Route Segment Config
// app/blog/page.tsx
// opt into dynamic rendering
export const dynamic = 'force-dynamic'
// or force static
export const dynamic = 'force-static'
// revalidate every 60 seconds
export const revalidate = 60
// choose runtime
export const runtime = 'edge' // or 'nodejs'Last updated on