I still remember the first time I opened the Next.js App Router documentation. After years of working with the Pages Router, I felt like I was learning a new framework entirely. The concepts seemed foreign—server components, parallel routes, intercepting routes—what did it all mean? Fast forward to today, and I can't imagine building a Next.js application any other way.
Let me share everything I've learned about mastering the App Router, complete with real examples and practical patterns I use daily.
1. Introduction: Understanding the App Router
What is the App Router?
The App Router is Next.js's modern routing system, introduced in version 13 and stabilized in version 13.4. Unlike the older Pages Router that used file-based routing with a pages/ directory, the App Router uses an app/ directory with enhanced features and a fundamentally different architecture.
Pages Router (the old way):
pages/
index.js
about.js
blog/
[slug].js
App Router (the new way):
app/
page.tsx
about/
page.tsx
blog/
[slug]/
page.tsx
Key Benefits
Server Components by Default: Every component in the App Router is a React Server Component unless you explicitly mark it as a client component with 'use client'. This means:
- Smaller JavaScript bundles shipped to the browser
- Direct database queries without API routes
- Automatic code splitting
Simplified Data Fetching: Remember creating API routes just to fetch data? Those days are over. You can now fetch directly in your components:
// app/posts/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts')
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts()
return (
<div>
{posts.map(post => (
<article key={post.id}>{post.title}</article>
))}
</div>
)
}
Code Co-location: You can place components, styles, tests, and utilities right next to the routes that use them. This keeps related code together and makes refactoring much easier.
Who Should Care?
If you're maintaining a Pages Router app and wondering whether to migrate, or starting a fresh project and debating which router to use, here's my take:
- New projects: Use App Router without hesitation
- Existing large apps: Gradual migration is supported and practical
- Small existing apps: Migration might take a weekend and is worth it
2. Project Structure & Folder Conventions
After building several production apps with the App Router, I've settled on this structure:
my-app/
├── app/
│ ├── (auth)/
│ │ ├── login/
│ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── (dashboard)/
│ │ ├── analytics/
│ │ │ ├── page.tsx
│ │ │ └── loading.tsx
│ │ ├── settings/
│ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── api/
│ │ └── webhooks/
│ │ └── route.ts
│ ├── layout.tsx
│ ├── page.tsx
│ └── global.css
├── components/
│ ├── ui/
│ │ ├── button.tsx
│ │ └── card.tsx
│ └── shared/
│ ├── header.tsx
│ └── footer.tsx
├── lib/
│ ├── db.ts
│ ├── utils.ts
│ └── validation.ts
└── public/
└── images/
Special File Conventions
The App Router introduces special file names that Next.js recognizes:
page.tsx: Creates a publicly accessible routelayout.tsx: Wraps pages with shared UI (persists across navigation)template.tsx: Like layout, but re-renders on navigationloading.tsx: Automatic loading UI with Suspenseerror.tsx: Error boundary for the route segmentnot-found.tsx: Custom 404 UIroute.ts: API endpoint (replaces Pages Router's API routes)
Important: Only page.tsx files create accessible routes. You can have a folder without a page.tsx, and it won't be routable.
File Colocation Example
Here's a real pattern I use for complex features:
app/
products/
[id]/
page.tsx # The route
loading.tsx # Loading state
error.tsx # Error boundary
product-gallery.tsx # Component used only here
product-details.tsx # Another local component
actions.ts # Server actions for this route
types.ts # TypeScript types
This keeps everything related to the product detail page in one place. When I need to refactor or remove this feature, I know exactly where everything lives.
3. Layouts: Building Reusable UI
Layouts are where the App Router really shines. They let you wrap multiple pages with shared UI that persists across navigation—no re-mounting, no flickering.
Root Layout: The Foundation
Every App Router project needs a root layout at app/layout.tsx:
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<header>
<nav>
{/* Your navigation */}
</nav>
</header>
<main>{children}</main>
<footer>
{/* Your footer */}
</footer>
</body>
</html>
)
}
Critical rule: The root layout must contain <html> and <body> tags. This is the only layout where you need them.
Nested Layouts: Composable UI
Layouts can nest, creating composition patterns. Here's a dashboard example I built:
// app/(dashboard)/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex h-screen">
<aside className="w-64 bg-gray-100 p-4">
<DashboardSidebar />
</aside>
<div className="flex-1 overflow-auto">
{children}
</div>
</div>
)
}
Now every route under (dashboard)/ gets this sidebar automatically:
/dashboard/analytics→ Has sidebar/dashboard/settings→ Has sidebar/login→ No sidebar (different route group)
Template vs Layout: When to Re-render
Here's something that confused me initially: the difference between template.tsx and layout.tsx.
Layout: Maintains state between navigations Template: Re-creates state on every navigation
Use layout when:
- You have a sidebar that should keep scroll position
- You're maintaining open/closed state of a collapsible menu
- You want better performance (no re-render)
Use template when:
- You need CSS animations on route changes
- You want to reset form state between pages
- You're tracking page views (needs to fire on each navigation)
Example template for page transition animations:
// app/template.tsx
'use client'
import { motion } from 'framer-motion'
export default function Template({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
)
}
Best Practices for Layouts
1. Avoid Prop Drilling with Server Components
Instead of passing data through multiple layout levels, fetch where you need it:
// ❌ Don't do this - prop drilling through layouts
export default async function Layout({ user, children }) {
return <Sidebar user={user}>{children}</Sidebar>
}
// ✅ Do this - fetch directly in the component that needs it
async function Sidebar() {
const user = await getCurrentUser()
return <nav>{user.name}</nav>
}
2. Lazy Load Heavy Shared Components
import dynamic from 'next/dynamic'
const HeavyAnalyticsWidget = dynamic(() =>
import('@/components/analytics-widget'),
{
loading: () => <AnalyticsSkeleton />,
ssr: false
}
)
3. Use Context Sparingly
Since layouts are server components by default, you can't use React Context directly. When you need shared client state:
// app/providers.tsx
'use client'
import { createContext, useContext } from 'react'
const ThemeContext = createContext({})
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeContext.Provider value={{ theme: 'dark' }}>
{children}
</ThemeContext.Provider>
)
}
// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
4. Nested Routes & Route Groups
Defining Child Routes
Routes nest based on folder structure. Each folder represents a URL segment:
app/
blog/ → /blog
[slug]/ → /blog/my-post
page.tsx
categories/ → /blog/categories
[id]/ → /blog/categories/tech
page.tsx
Route Groups: Organization Without URLs
Route groups use parentheses (name) to organize files without adding URL segments. This is brilliant for organizing large apps:
app/
(marketing)/
page.tsx → /
about/
page.tsx → /about
layout.tsx # Marketing layout
(shop)/
products/
page.tsx → /products
cart/
page.tsx → /cart
layout.tsx # Shop layout
Both route groups share the same root URL namespace but can have completely different layouts.
Real Pattern: Admin Dashboard
Here's how I structure admin sections:
app/
(admin)/
dashboard/
page.tsx → /dashboard
users/
page.tsx → /users
[id]/
edit/
page.tsx → /users/123/edit
layout.tsx # Admin layout with auth check
The admin layout handles authentication:
// app/(admin)/layout.tsx
import { redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth'
export default async function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getServerSession()
if (!session?.user?.isAdmin) {
redirect('/login')
}
return (
<div>
<AdminHeader />
{children}
</div>
)
}
Dynamic Segments with TypeScript
Type safety for dynamic routes is crucial:
// app/products/[id]/page.tsx
type Props = {
params: { id: string }
searchParams: { [key: string]: string | string[] | undefined }
}
export default async function ProductPage({ params, searchParams }: Props) {
const product = await getProduct(params.id)
const sortOrder = searchParams.sort as string | undefined
return <ProductDetails product={product} />
}
// Generate static params for build time
export async function generateStaticParams() {
const products = await getProducts()
return products.map((product) => ({
id: product.id.toString(),
}))
}
Catch-all routes use [...slug]:
app/
docs/
[...slug]/
page.tsx
// Matches:
// /docs/introduction
// /docs/api/authentication
// /docs/guides/getting-started/installation
Optional catch-all uses [[...slug]]:
app/
shop/
[[...categories]]/
page.tsx
// Matches:
// /shop (categories is undefined)
// /shop/electronics
// /shop/electronics/laptops
5. Parallel Routes
Parallel routes let you render multiple pages in the same layout simultaneously. They use named slots with the @folder convention.
The Concept
Think of parallel routes as rendering multiple independent "pages" side by side, each with their own loading and error states.
app/
dashboard/
@analytics/
page.tsx
@recent/
page.tsx
layout.tsx
page.tsx
The layout receives each slot as a prop:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
recent,
}: {
children: React.ReactNode
analytics: React.ReactNode
recent: React.ReactNode
}) {
return (
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">{children}</div>
<aside>
<section>{analytics}</section>
<section>{recent}</section>
</aside>
</div>
)
}
Real Use Case: Messaging App
I built a messaging interface using parallel routes:
app/
messages/
@list/
page.tsx # Message list
loading.tsx # List loading state
@conversation/
[id]/
page.tsx # Message thread
default.tsx # Empty state
layout.tsx
// app/messages/layout.tsx
export default function MessagesLayout({
list,
conversation,
}: {
list: React.ReactNode
conversation: React.ReactNode
}) {
return (
<div className="flex h-screen">
<div className="w-80 border-r">{list}</div>
<div className="flex-1">{conversation}</div>
</div>
)
}
The magic: When you navigate to /messages/123, only the conversation slot updates. The list stays rendered with its scroll position preserved.
State Preservation
Parallel routes maintain their own state. If the @list slot loads data, that data persists even when you navigate between conversations.
Combining with Suspense
Each parallel route can have independent loading states:
// app/dashboard/@analytics/loading.tsx
export default function AnalyticsLoading() {
return <AnalyticsSkeleton />
}
// app/dashboard/@recent/loading.tsx
export default function RecentLoading() {
return <RecentActivitySkeleton />
}
The dashboard shows immediately with skeletons while each section loads independently. It's a significant UX improvement over blocking the entire page.
Default.tsx for Soft Navigation
When using parallel routes, you need default.tsx files to handle soft navigation (client-side routing):
// app/dashboard/@analytics/default.tsx
export default function Default() {
return null // or a default state
}
Without this, navigating to a sibling route might cause the slot to disappear.
6. Intercepting Routes
Intercepting routes are one of the most powerful—and initially confusing—features. They let you load a route in a modal or drawer while keeping the background page visible.
The Syntax
Intercepting routes use special folder prefixes:
(.)- Same level(..)- One level up(..)(..)- Two levels up(...)- From app root
Instagram-Style Photo Modal
This is the classic example everyone shows, but here's a complete implementation:
app/
photos/
page.tsx # Photo grid
[id]/
page.tsx # Full photo page
@modal/
(.)photos/
[id]/
page.tsx # Modal version
layout.tsx
// app/photos/layout.tsx
export default function PhotosLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<>
{children}
{modal}
</>
)
}
// app/photos/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/modal'
import { getPhoto } from '@/lib/photos'
export default async function PhotoModal({
params
}: {
params: { id: string }
}) {
const photo = await getPhoto(params.id)
return (
<Modal>
<img src={photo.url} alt={photo.title} />
</Modal>
)
}
The behavior:
- Click a photo in the grid → Modal opens with the photo, URL changes to
/photos/123, background stays visible - Refresh the page or share the link → Full page loads at
/photos/123 - Close modal → Navigate back to
/photos
Drawer Navigation Pattern
I use this for mobile-friendly detail views:
// components/drawer.tsx
'use client'
import { useRouter } from 'next/navigation'
import { Dialog } from '@headlessui/react'
export function Drawer({ children }: { children: React.ReactNode }) {
const router = useRouter()
return (
<Dialog
open={true}
onClose={() => router.back()}
className="relative z-50"
>
<div className="fixed inset-0 bg-black/30" />
<div className="fixed inset-y-0 right-0 w-96 bg-white p-6">
{children}
</div>
</Dialog>
)
}
Best Practices
1. Manage Focus and Accessibility
'use client'
import { useEffect, useRef } from 'react'
export function Modal({ children }: { children: React.ReactNode }) {
const closeButtonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
closeButtonRef.current?.focus()
// Trap focus in modal
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
router.back()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
return (
<div role="dialog" aria-modal="true">
{children}
</div>
)
}
2. SEO Considerations
Both the full page and intercepted route should have proper metadata:
// app/products/[id]/page.tsx
export async function generateMetadata({ params }: { params: { id: string } }) {
const product = await getProduct(params.id)
return {
title: product.name,
description: product.description,
openGraph: {
images: [product.image],
},
}
}
The intercepted route inherits this metadata automatically.
3. Prevent Body Scroll
'use client'
import { useEffect } from 'react'
export function Modal({ children }: { children: React.ReactNode }) {
useEffect(() => {
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = 'unset'
}
}, [])
return <div className="modal">{children}</div>
}
7. Data Fetching & React Server Components
This is where everything clicks. Server Components fundamentally change how we think about data fetching.
Why RSC Matters
Traditional React apps fetch data client-side:
- Ship JavaScript bundle to browser
- Browser parses and executes code
- Code makes API request
- Wait for response
- Render with data
With Server Components:
- Fetch data on the server
- Render components with data
- Stream HTML to browser
- Browser displays content (no JS needed for static content)
Fetching in Layouts vs Pages
In Layouts - For data needed by the entire section:
// app/(dashboard)/layout.tsx
async function getUser() {
const session = await getServerSession()
return session.user
}
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const user = await getUser()
return (
<div>
<DashboardNav user={user} />
{children}
</div>
)
}
In Pages - For route-specific data:
// app/blog/[slug]/page.tsx
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 3600 } // Cache for 1 hour
})
return res.json()
}
async function getRelatedPosts(postId: string) {
const res = await fetch(`https://api.example.com/posts/${postId}/related`)
return res.json()
}
export default async function BlogPost({
params
}: {
params: { slug: string }
}) {
// These fetch in parallel automatically
const [post, related] = await Promise.all([
getPost(params.slug),
getRelatedPosts(params.slug)
])
return (
<article>
<h1>{post.title}</h1>
<PostContent content={post.content} />
<RelatedPosts posts={related} />
</article>
)
}
Fetch Caching Options
Next.js extends the native fetch with caching options:
// Cached by default (static)
fetch('https://api.example.com/data')
// Opt out of caching (dynamic)
fetch('https://api.example.com/data', {
cache: 'no-store'
})
// Revalidate every hour
fetch('https://api.example.com/data', {
next: { revalidate: 3600 }
})
// Tag-based revalidation
fetch('https://api.example.com/data', {
next: { tags: ['products'] }
})
Combining RSC with Client Components
Here's a pattern I use constantly—server components for data, client components for interactivity:
// app/products/page.tsx (Server Component)
import { ProductGrid } from './product-grid'
async function getProducts() {
const res = await fetch('https://api.example.com/products')
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts()
return (
<div>
<h1>Products</h1>
<ProductGrid products={products} />
</div>
)
}
// app/products/product-grid.tsx (Client Component)
'use client'
import { useState } from 'react'
export function ProductGrid({ products }: { products: Product[] }) {
const [filter, setFilter] = useState('')
const filtered = products.filter(p =>
p.name.toLowerCase().includes(filter.toLowerCase())
)
return (
<div>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter products..."
/>
<div className="grid grid-cols-3 gap-4">
{filtered.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
)
}
The server fetches products, and the client handles filtering. Best of both worlds.
Direct Database Queries
One of my favorite patterns—no API layer needed:
// lib/db.ts
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export { prisma }
// app/users/page.tsx
import { prisma } from '@/lib/db'
export default async function UsersPage() {
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
},
orderBy: {
createdAt: 'desc'
},
take: 50,
})
return (
<div>
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
)
}
No API route, no extra network hop. Just fetch and render.
8. Performance & Caching Strategies
Static vs Dynamic Rendering
Next.js automatically determines if a route can be statically rendered:
Static (default):
- No dynamic functions (
cookies(),headers(),searchParams) - All fetches are cached
- Rendered at build time
Dynamic:
- Uses dynamic functions
- Has uncached fetches
- Rendered per request
Force static with generateStaticParams:
// app/products/[id]/page.tsx
export async function generateStaticParams() {
const products = await getProducts()
return products.map((product) => ({
id: product.id,
}))
}
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
const product = await getProduct(params.id)
return <ProductDetails product={product} />
}
This generates static pages for all products at build time.
Incremental Static Regeneration
ISR lets you update static pages after build:
// app/blog/[slug]/page.tsx
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 60 } // Revalidate every 60 seconds
})
return res.json()
}
How it works:
- First request: Serve stale content (generated at build)
- Background: Fetch fresh data
- Next request: Serve updated content
Tag-Based Revalidation
For more control, use tags:
// app/products/page.tsx
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { tags: ['products'] }
})
return res.json()
}
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
const { tag } = await request.json()
revalidateTag(tag)
return Response.json({ revalidated: true })
}
Now you can revalidate on-demand:
curl -X POST http://localhost:3000/api/revalidate \
-H "Content-Type: application/json" \
-d '{"tag":"products"}'
Path-Based Revalidation
Revalidate specific paths:
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
// Create post logic
await db.post.create({ ... })
revalidatePath('/blog')
revalidatePath('/blog/[slug]', 'page')
}
Route Segment Config
Configure caching per route:
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic' // Always dynamic
export const revalidate = 3600 // Revalidate every hour
export const fetchCache = 'force-no-store' // Never cache fetches
export default async function Dashboard() {
const data = await getDashboardData()
return <DashboardView data={data} />
}
9. Error & Loading States
The App Router makes error and loading states elegant with convention-based files.
Loading States
Create a loading.tsx file and it automatically wraps your page with Suspense:
// app/products/loading.tsx
export default function ProductsLoading() {
return (
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-48 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded mt-2" />
<div className="h-4 bg-gray-200 rounded mt-2 w-2/3" />
</div>
))}
</div>
)
}
Error Boundaries
error.tsx creates automatic error boundaries:
// app/products/error.tsx
'use client'
import { useEffect } from 'react'
export default function ProductsError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log to error reporting service
console.error('Products error:', error)
}, [error])
return (
<div className="flex flex-col items-center justify-center min-h-96">
<h2 className="text-2xl font-bold mb-4">Something went wrong!</h2>
<p className="text-gray-600 mb-6">
We couldn't load the products. Please try again.
</p>
<button
onClick={reset}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Try again
</button>
</div>
)
}
User-Friendly Patterns
Here's a more sophisticated error component I use:
// app/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
const isDevelopment = process.env.NODE_ENV === 'development'
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-6">
<div className="flex items-center mb-4">
<div className="flex-shrink-0">
<svg className="h-12 w-12 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div className="ml-4">
<h2 className="text-lg font-semibold text-gray-900">
Oops! Something went wrong
</h2>
</div>
</div>
{isDevelopment && (
<div className="mb-4 p-3 bg-gray-100 rounded">
<p className="text-sm font-mono text-gray-700">{error.message}</p>
</div>
)}
<p className="text-gray-600 mb-6">
Don't worry, these things happen. Try refreshing the page or contact support if the problem persists.
</p>
<div className="flex gap-3">
<button
onClick={reset}
className="flex-1 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Try Again
</button>
href="/"
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300 text-center" > Go Home </a>
</div>
</div>
</div>
)
}
Handling Async Errors in Server Components
Server component errors are caught by the nearest error boundary:
// app/posts/[id]/page.tsx
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`)
if (!res.ok) {
throw new Error('Failed to fetch post')
}
return res.json()
}
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id)
if (!post) {
throw new Error('Post not found')
}
return <PostContent post={post} />
}
Custom Not Found Pages
Use not-found.tsx for 404 errors:
// app/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-6xl font-bold text-gray-900 mb-4">404</h1>
<h2 className="text-2xl font-semibold text-gray-700 mb-4">
Page Not Found
</h2>
<p className="text-gray-600 mb-8">
The page you're looking for doesn't exist or has been moved.
</p>
<Link
href="/"
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
Back to Home
</Link>
</div>
</div>
)
}
Trigger it programmatically:
import { notFound } from 'next/navigation'
export default async function UserPage({ params }: { params: { id: string } }) {
const user = await getUser(params.id)
if (!user) {
notFound()
}
return <UserProfile user={user} />
}
Nested Error Boundaries
Each route segment can have its own error boundary:
app/
dashboard/
error.tsx # Catches errors in all dashboard routes
analytics/
error.tsx # Catches only analytics errors
page.tsx
settings/
page.tsx
This provides granular error handling without breaking the entire app.
10. Migration Tips
Migrating from Pages Router to App Router doesn't have to be all-or-nothing. Here's what I've learned from several migrations.
Gradual Adoption Strategy
Next.js supports both routers simultaneously:
my-app/
├── app/
│ ├── dashboard/ # New App Router routes
│ │ └── page.tsx
│ └── layout.tsx
├── pages/
│ ├── index.tsx # Old Pages Router routes
│ ├── about.tsx
│ └── api/
│ └── users.ts
My recommended migration order:
- Create root layout in
app/layout.tsx - Migrate one feature at a time (e.g., dashboard)
- Move API routes to Route Handlers
- Update shared components to be RSC-compatible
- Finally, migrate remaining pages
Common Pitfalls
1. Client vs Server Components Confusion
This tripped me up constantly at first:
// ❌ Won't work - trying to use useState in a Server Component
export default function Page() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
// ✅ Add 'use client' directive
'use client'
export default function Page() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
2. Middleware Differences
App Router middleware runs on all routes by default:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// This now runs for BOTH app/ and pages/ routes
const token = request.cookies.get('token')
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
// Optionally configure which routes to match
export const config = {
matcher: [
'/dashboard/:path*',
'/api/:path*',
]
}
3. Data Fetching Patterns
Pages Router getServerSideProps:
// pages/posts.tsx
export async function getServerSideProps() {
const posts = await getPosts()
return { props: { posts } }
}
export default function Posts({ posts }) {
return <PostList posts={posts} />
}
App Router equivalent:
// app/posts/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts')
return res.json()
}
export default async function Posts() {
const posts = await getPosts()
return <PostList posts={posts} />
}
4. getStaticProps Becomes Default Behavior
// pages/blog/[slug].tsx - Pages Router
export async function getStaticProps({ params }) {
const post = await getPost(params.slug)
return {
props: { post },
revalidate: 60
}
}
export async function getStaticPaths() {
const posts = await getPosts()
return {
paths: posts.map(post => ({ params: { slug: post.slug } })),
fallback: 'blocking'
}
}
// app/blog/[slug]/page.tsx - App Router
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map(post => ({ slug: post.slug }))
}
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 60 }
})
return res.json()
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug)
return <PostContent post={post} />
}
Handling useRouter Changes
The router API changed:
// Pages Router
import { useRouter } from 'next/router'
function Component() {
const router = useRouter()
const { id } = router.query
router.push('/new-page')
}
// App Router
'use client'
import { useRouter, useParams, useSearchParams } from 'next/navigation'
function Component() {
const router = useRouter()
const params = useParams()
const searchParams = useSearchParams()
const id = params.id
router.push('/new-page')
}
Link Component Changes
The <Link> component no longer requires a child <a> tag:
// Pages Router
import Link from 'next/link'
<Link href="/about">
<a>About</a>
</Link>
// App Router
import Link from 'next/link'
<Link href="/about">
About
</Link>
Image Component Remains Similar
Good news—next/image works the same way:
import Image from 'next/image'
<Image
src="/profile.jpg"
alt="Profile"
width={500}
height={500}
/>
11. Testing & Tooling
Testing Setup with Playwright
Here's my testing setup for App Router apps:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
fullyParallel: true,
use: {
baseURL: 'http://localhost:3000',
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
})
Test navigation and loading states:
// tests/products.spec.ts
import { test, expect } from '@playwright/test'
test('product list loads and displays items', async ({ page }) => {
await page.goto('/products')
// Should show loading state initially
await expect(page.getByTestId('product-skeleton')).toBeVisible()
// Then show actual products
await expect(page.getByTestId('product-skeleton')).not.toBeVisible()
await expect(page.getByRole('heading', { name: /products/i })).toBeVisible()
// Should have product cards
const products = page.getByTestId('product-card')
await expect(products).toHaveCount(9)
})
test('clicking product opens modal on same page', async ({ page }) => {
await page.goto('/products')
// Click first product
await page.getByTestId('product-card').first().click()
// URL should change but page background should remain
await expect(page).toHaveURL(/\/products\/\d+/)
// Modal should be visible
await expect(page.getByRole('dialog')).toBeVisible()
// Background products should still be visible
await expect(page.getByTestId('product-grid')).toBeVisible()
})
Component Testing with React Testing Library
For client components:
// components/product-filter.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { ProductFilter } from './product-filter'
describe('ProductFilter', () => {
const mockProducts = [
{ id: 1, name: 'Laptop', category: 'electronics' },
{ id: 2, name: 'Shirt', category: 'clothing' },
{ id: 3, name: 'Phone', category: 'electronics' },
]
it('filters products by search term', () => {
render(<ProductFilter products={mockProducts} />)
const searchInput = screen.getByPlaceholderText(/search/i)
fireEvent.change(searchInput, { target: { value: 'laptop' } })
expect(screen.getByText('Laptop')).toBeInTheDocument()
expect(screen.queryByText('Shirt')).not.toBeInTheDocument()
})
it('filters products by category', () => {
render(<ProductFilter products={mockProducts} />)
const categorySelect = screen.getByLabelText(/category/i)
fireEvent.change(categorySelect, { target: { value: 'electronics' } })
expect(screen.getByText('Laptop')).toBeInTheDocument()
expect(screen.getByText('Phone')).toBeInTheDocument()
expect(screen.queryByText('Shirt')).not.toBeInTheDocument()
})
})
Type Safety with TypeScript
Leverage TypeScript for route params:
// types/routes.ts
export type RouteParams<T extends Record<string, string>> = {
params: T
searchParams: { [key: string]: string | string[] | undefined }
}
// app/products/[id]/page.tsx
import type { RouteParams } from '@/types/routes'
type ProductPageParams = RouteParams<{ id: string }>
export default async function ProductPage({
params,
searchParams
}: ProductPageParams) {
// params.id is typed as string
// searchParams is typed properly
}
Create typed navigation helpers:
// lib/navigation.ts
export const routes = {
home: '/',
products: '/products',
product: (id: string | number) => `/products/${id}`,
dashboard: {
root: '/dashboard',
analytics: '/dashboard/analytics',
settings: '/dashboard/settings',
},
} as const
// Usage
import Link from 'next/link'
import { routes } from '@/lib/navigation'
<Link href={routes.product(123)}>View Product</Link>
ESLint Configuration
Catch common routing mistakes:
// .eslintrc.json
{
"extends": ["next/core-web-vitals"],
"rules": {
"@next/next/no-html-link-for-pages": "error",
"react/no-unescaped-entities": "error"
}
}
Useful VS Code Extensions
My recommended setup:
- ES7+ React/Redux/React-Native snippets - Quick component scaffolding
- Tailwind CSS IntelliSense - Auto-complete for Tailwind classes
- Pretty TypeScript Errors - Better error messages
- Error Lens - Inline error highlighting
Custom Snippets
I created this snippet for new pages:
// .vscode/nextjs-page.code-snippets
{
"Next.js App Router Page": {
"prefix": "npage",
"body": [
"export default async function ${1:Page}() {",
" return (",
" <div>",
" <h1>${1:Page}</h1>",
" </div>",
" )",
"}"
]
}
}
12. Security & SEO Considerations
Dynamic Metadata API
The App Router has a powerful metadata system:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
export async function generateMetadata({
params
}: {
params: { slug: string }
}): Promise<Metadata> {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
}
],
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author.name],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
alternates: {
canonical: `https://example.com/blog/${params.slug}`,
},
}
}
Preventing Data Leaks in Server Components
This is critical—server components can accidentally expose sensitive data:
// ❌ DANGEROUS - exposes API keys to client
async function UserProfile({ userId }: { userId: string }) {
const user = await fetch(`https://api.example.com/users/${userId}`, {
headers: {
'Authorization': `Bearer ${process.env.SECRET_API_KEY}`
}
}).then(res => res.json())
return <div>{JSON.stringify(user)}</div> // Entire response sent to client
}
// ✅ SAFE - only sends necessary data
async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId)
// Only select safe fields
const safeUser = {
name: user.name,
avatar: user.avatar,
bio: user.bio,
}
return <UserDisplay user={safeUser} />
}
Best practices:
- Never pass entire database records to client components
- Use explicit field selection in database queries
- Create DTOs (Data Transfer Objects) for client-facing data
// lib/dto.ts export function toPublicUser(user: User) { return { id: user.id, name: user.name, avatar: user.avatar, bio: user.bio, // Explicitly omit: email, password, apiKeys, etc. } }
Authentication in Layouts
Check auth at the layout level for protected routes:
// app/(protected)/layout.tsx
import { redirect } from 'next/navigation'
import { getServerSession } from '@/lib/auth'
export default async function ProtectedLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getServerSession()
if (!session) {
redirect('/login')
}
return <>{children}</>
}
Middleware for Authentication
Use middleware for edge-level auth checks:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyAuth } from '@/lib/auth'
export async function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value
// Public routes
if (request.nextUrl.pathname.startsWith('/login')) {
return NextResponse.next()
}
// Protected routes
if (request.nextUrl.pathname.startsWith('/dashboard')) {
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
const isValid = await verifyAuth(token)
if (!isValid) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/login'],
}
Environment Variables
Server-side only variables:
// app/api/data/route.ts
export async function GET() {
// This is safe - only runs on server
const apiKey = process.env.SECRET_API_KEY
const data = await fetch('https://api.example.com/data', {
headers: { 'Authorization': `Bearer ${apiKey}` }
})
return Response.json(data)
}
Client-side variables (must prefix with NEXT_PUBLIC_):
// app/analytics.tsx
'use client'
export function Analytics() {
// This is exposed to the browser
const analyticsId = process.env.NEXT_PUBLIC_ANALYTICS_ID
useEffect(() => {
// Initialize analytics
}, [])
return null
}
Content Security Policy
Add CSP headers in middleware or next.config.js:
// next.config.js
const cspHeader = `
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' blob: data: https:;
font-src 'self';
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
`
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: cspHeader.replace(/\n/g, ''),
},
],
},
]
},
}
13. Real-World Example: E-commerce Dashboard
Let me walk you through a complete example that brings everything together—an e-commerce dashboard with modals, parallel routes, and server components.
Project Structure
app/
(dashboard)/
layout.tsx
page.tsx
products/
@modal/
(.)products/
[id]/
page.tsx
[id]/
page.tsx
edit/
page.tsx
page.tsx
loading.tsx
actions.ts
analytics/
@charts/
page.tsx
@recent/
page.tsx
layout.tsx
page.tsx
Root Dashboard Layout
// app/(dashboard)/layout.tsx
import { Sidebar } from '@/components/dashboard/sidebar'
import { Header } from '@/components/dashboard/header'
import { getServerSession } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getServerSession()
if (!session) {
redirect('/login')
}
return (
<div className="min-h-screen bg-gray-50">
<Header user={session.user} />
<div className="flex">
<Sidebar />
<main className="flex-1 p-8">
{children}
</main>
</div>
</div>
)
}
Products List with Modal Intercept
// app/(dashboard)/products/page.tsx
import { prisma } from '@/lib/db'
import { ProductCard } from '@/components/products/product-card'
import Link from 'next/link'
async function getProducts() {
return await prisma.product.findMany({
select: {
id: true,
name: true,
price: true,
image: true,
stock: true,
},
orderBy: { createdAt: 'desc' },
})
}
export default async function ProductsPage() {
const products = await getProducts()
return (
<div>
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Products</h1>
<Link
href="/products/new"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Add Product
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
)
}
Product Card Component
// components/products/product-card.tsx
import Link from 'next/link'
import Image from 'next/image'
type Product = {
id: number
name: string
price: number
image: string
stock: number
}
export function ProductCard({ product }: { product: Product }) {
return (
<Link href={`/products/${product.id}`}>
<div className="bg-white rounded-lg shadow hover:shadow-lg transition-shadow cursor-pointer">
<div className="relative h-48">
<Image
src={product.image}
alt={product.name}
fill
className="object-cover rounded-t-lg"
/>
</div>
<div className="p-4">
<h3 className="font-semibold text-lg mb-2">{product.name}</h3>
<div className="flex justify-between items-center">
<span className="text-2xl font-bold">
${product.price.toFixed(2)}
</span>
<span className={`px-2 py-1 rounded text-sm ${
product.stock > 0
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{product.stock > 0 ? `${product.stock} in stock` : 'Out of stock'}
</span>
</div>
</div>
</div>
</Link>
)
}
Intercepted Modal Route
// app/(dashboard)/products/@modal/(.)products/[id]/page.tsx
import { prisma } from '@/lib/db'
import { Modal } from '@/components/ui/modal'
import { ProductDetails } from '@/components/products/product-details'
async function getProduct(id: string) {
return await prisma.product.findUnique({
where: { id: parseInt(id) },
include: {
category: true,
reviews: {
take: 5,
orderBy: { createdAt: 'desc' },
},
},
})
}
export default async function ProductModal({
params,
}: {
params: { id: string }
}) {
const product = await getProduct(params.id)
if (!product) {
return <div>Product not found</div>
}
return (
<Modal>
<ProductDetails product={product} />
</Modal>
)
}
Modal Component
// components/ui/modal.tsx
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter()
const dialogRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') router.back()
}
const handleClickOutside = (e: MouseEvent) => {
if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
router.back()
}
}
document.addEventListener('keydown', handleEscape)
document.addEventListener('mousedown', handleClickOutside)
// Prevent body scroll
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', handleEscape)
document.removeEventListener('mousedown', handleClickOutside)
document.body.style.overflow = 'unset'
}
}, [router])
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" />
<div
ref={dialogRef}
className="relative bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-auto m-4"
role="dialog"
aria-modal="true"
>
<button
onClick={() => router.back()}
className="absolute top-4 right-4 text-gray-500 hover:text-gray-700"
aria-label="Close"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{children}
</div>
</div>,
document.body
)
}
Full Product Page
// app/(dashboard)/products/[id]/page.tsx
import { prisma } from '@/lib/db'
import { ProductDetails } from '@/components/products/product-details'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
async function getProduct(id: string) {
return await prisma.product.findUnique({
where: { id: parseInt(id) },
include: {
category: true,
reviews: true,
},
})
}
export async function generateMetadata({
params
}: {
params: { id: string }
}): Promise<Metadata> {
const product = await getProduct(params.id)
if (!product) {
return { title: 'Product Not Found' }
}
return {
title: product.name,
description: product.description,
openGraph: {
images: [product.image],
},
}
}
export default async function ProductPage({
params,
}: {
params: { id: string }
}) {
const product = await getProduct(params.id)
if (!product) {
notFound()
}
return (
<div className="max-w-6xl mx-auto">
<ProductDetails product={product} />
</div>
)
}
Analytics with Parallel Routes
// app/(dashboard)/analytics/layout.tsx
export default function AnalyticsLayout({
children,
charts,
recent,
}: {
children: React.ReactNode
charts: React.ReactNode
recent: React.ReactNode
}) {
return (
<div>
<h1 className="text-3xl font-bold mb-8">Analytics Dashboard</h1>
<div className="grid grid-cols-3 gap-6">
<div className="col-span-2">
{children}
<div className="mt-6">{charts}</div>
</div>
<aside>{recent}</aside>
</div>
</div>
)
}
Server Actions for Mutations
// app/(dashboard)/products/actions.ts
'use server'
import { prisma } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function updateProduct(formData: FormData) {
const id = formData.get('id') as string
const name = formData.get('name') as string
const price = parseFloat(formData.get('price') as string)
const stock = parseInt(formData.get('stock') as string)
await prisma.product.update({
where: { id: parseInt(id) },
data: { name, price, stock },
})
revalidatePath('/products')
revalidatePath(`/products/${id}`)
redirect('/products')
}
export async function deleteProduct(id: number) {
await prisma.product.delete({
where: { id },
})
revalidatePath('/products')
redirect('/products')
}
This example demonstrates:
- Nested layouts for dashboard structure
- Intercepting routes for modal product views
- Parallel routes for analytics dashboard
- Server components for data fetching
- Server actions for mutations
- Type-safe routing with TypeScript
- Metadata API for SEO
14. Conclusion & Further Resources
Key Takeaways
After building multiple production apps with the App Router, here are my most important lessons:
1. Embrace Server Components They're not just an optimization—they fundamentally change how you architect applications. Fetch data where you need it, no prop drilling required.
2. Start Simple Don't use parallel and intercepting routes just because they exist. Use them when they solve real UX problems: modals that preserve context, split views with independent loading states.
3. Type Everything TypeScript makes the App Router significantly better. Type your route params, search params, and server actions.
4. Test the User Flow Focus on end-to-end tests with Playwright. The App Router's streaming and suspense boundaries make traditional component testing less relevant.
5. Migration is Incremental You don't need to migrate everything at once. The Pages and App routers coexist peacefully.
Common Mistakes to Avoid
❌ Using client components everywhere because you're used to it
✅ Default to server components, add 'use client' only when needed
❌ Prop drilling through layouts
✅ Fetch data in the component that needs it
❌ Over-engineering with route groups
✅ Use route groups for clear organizational benefits, not just because
❌ Ignoring loading states
✅ Add loading.tsx files for better UX during data fetching
❌ Exposing sensitive data in server components
✅ Filter data before passing to client components
❌ Not using TypeScript
✅ Leverage types for route params and better DX
Performance Checklist
Before deploying, I run through this checklist:
- Images use
next/imagewith proper sizing - Heavy components are dynamically imported
- Database queries use proper indexing
- Static routes have
generateStaticParams - Fetch requests have appropriate caching strategies
- Loading states exist for slow operations
- Error boundaries catch and handle errors gracefully
- Metadata is complete for SEO
- Client components are minimized
Further Resources
Official Documentation
- Next.js App Router Docs - The source of truth
- React Server Components - Understanding the foundation
- Next.js Examples - Official example projects
Community Resources
- Next.js Discord - Active community for questions
- Lee Robinson's Blog - VP of Product at Vercel, great insights
- Theo's T3 Stack - Modern full-stack TypeScript
Video Tutorials
- Next.js Conf talks on YouTube - Yearly conference with deep dives
- Jack Herrington's channel - Practical Next.js tutorials
- Vercel's YouTube channel - Official tutorials and updates
Tools I Use Daily
- Tailwind UI - Component patterns
- shadcn/ui - Copy-paste React components
- Prisma - Type-safe database access
- Zod - Schema validation
- React Hook Form - Form handling
Real Production Tips
Here are some things I wish I'd known when starting with the App Router:
1. Monitor Build Times Server components can make builds slower if you're fetching data at build time. Use output: 'export' for truly static sites, or deploy to Vercel/similar platforms that handle this well.
2. Database Connection Pooling Server components run on every request in development. Use connection pooling or serverless-friendly database adapters:
// lib/db.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = global as unknown as { prisma: PrismaClient }
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
3. Debugging Server Components Use console.log liberally—they show in your terminal, not the browser console. This took me embarrassingly long to figure out.
4. Hot Reload Issues Sometimes Fast Refresh gets confused with server components. If things seem broken, restart your dev server. It's not you, it's the bleeding edge.
5. Environment Variables Remember: Server components can access ALL environment variables. Client components only see NEXT_PUBLIC_* variables. This is a feature, not a bug.
// ✅ This works - server component
async function getData() {
const apiKey = process.env.SECRET_API_KEY
// ...
}
// ❌ This is undefined - client component
'use client'
function Component() {
const apiKey = process.env.SECRET_API_KEY // undefined!
// ...
}
6. Caching Can Be Confusing The App Router aggressively caches by default. When things don't update as expected:
- Check your fetch cache options
- Use
cache: 'no-store'for debugging - Remember to
revalidatePath()orrevalidateTag()after mutations - Clear
.nextfolder and rebuild if really stuck
7. Middleware Performance Middleware runs on every request and can't access Node.js APIs. Keep it light:
// ❌ Don't do heavy operations in middleware
export async function middleware(request: NextRequest) {
const user = await db.user.findUnique(...) // Too slow!
// ...
}
// ✅ Do lightweight checks
export async function middleware(request: NextRequest) {
const token = request.cookies.get('token')
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
8. Deployment Gotchas
- Set
NODE_ENV=productionin your environment - Configure your database connection for serverless (if applicable)
- Set up proper CORS headers if you have external API consumers
- Use
output: 'standalone'for Docker deployments - Enable image optimization with proper domain configuration
Advanced Patterns Worth Exploring
Once you're comfortable with the basics, these patterns unlock even more power:
1. Streaming with Suspense Stream different parts of your page at different speeds:
import { Suspense } from 'react'
export default function Page() {
return (
<div>
<Header />
<Suspense fallback={<QuickDataSkeleton />}>
<QuickData />
</Suspense>
<Suspense fallback={<SlowDataSkeleton />}>
<SlowData />
</Suspense>
</div>
)
}
2. Optimistic Updates Use experimental useOptimistic for instant UI feedback:
'use client'
import { experimental_useOptimistic as useOptimistic } from 'react'
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo]
)
async function addTodo(formData: FormData) {
const todo = { id: Date.now(), text: formData.get('text') }
addOptimisticTodo(todo)
await createTodo(formData)
}
return (
<form action={addTodo}>
<input name="text" />
<button>Add</button>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</form>
)
}
3. Route Handlers with Streaming Stream large responses for better perceived performance:
// app/api/export/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const data = await getLargeDataset()
for (const chunk of data) {
controller.enqueue(encoder.encode(JSON.stringify(chunk) + '\n'))
await new Promise(resolve => setTimeout(resolve, 100)) // Simulate processing
}
controller.close()
}
})
return new Response(stream, {
headers: {
'Content-Type': 'application/json',
'Transfer-Encoding': 'chunked',
},
})
}
4. Progressive Enhancement with Server Actions Forms work without JavaScript:
// app/contact/page.tsx
import { submitContact } from './actions'
export default function ContactPage() {
return (
<form action={submitContact}>
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
)
}
// app/contact/actions.ts
'use server'
export async function submitContact(formData: FormData) {
const email = formData.get('email')
const message = formData.get('message')
await sendEmail({ email, message })
redirect('/contact/success')
}
The Future is Bright
The App Router represents a fundamental shift in how we build web applications. It's not just a new routing system—it's a new architecture that leverages modern React features to deliver better performance, better DX, and better user experiences.
Yes, there's a learning curve. Yes, some patterns feel foreign at first. But after building several production apps, I can confidently say: this is the future of Next.js, and it's worth learning.
The beauty of the App Router is that it makes the right thing easy:
- Want to fetch data? Just await it.
- Need loading states? Add a file.
- Want to handle errors? Add another file.
- Need a modal? Intercept a route.
- Want type safety? TypeScript has your back.
It removes boilerplate, reduces complexity, and lets you focus on building features instead of wiring up infrastructure.
Start Building
The best way to learn the App Router is to build something real with it. Pick a project—a dashboard, a blog, an e-commerce site—and dive in. You'll make mistakes, hit confusing errors, and question your choices. That's all part of the process.
But stick with it. Read error messages carefully. Check the documentation. Ask questions in the community. And soon, you'll find yourself thinking in server components, reaching for parallel routes naturally, and building faster, better applications than ever before.
The App Router isn't perfect, but it's powerful, well-designed, and constantly improving. It represents the cutting edge of web development, and I'm excited to see what you'll build with it.
Now go forth and create something amazing! 🚀
Have questions about the App Router or want to share what you're building? I'd love to hear from you. Drop your thoughts in the comments or reach out on Twitter. Happy coding!
