Next.js App Router Development Guide
This skill provides comprehensive guidance for building Next.js applications using the App Router (app directory).
Core Concepts
File-Based Routing
The App Router uses a file-system based router where:
- Folders define routes
- Files define UI for route segments
Special files:
| File | Purpose |
|---|---|
page.tsx |
Unique UI for a route (makes route publicly accessible) |
layout.tsx |
Shared UI for a segment and its children |
loading.tsx |
Loading UI (uses React Suspense) |
error.tsx |
Error UI (uses React Error Boundary) |
not-found.tsx |
Not found UI |
route.ts |
API endpoint (Route Handler) |
template.tsx |
Re-rendered layout |
default.tsx |
Fallback for parallel routes |
Server Components vs Client Components
Server Components (Default)
- All components in
app/are Server Components by default - Can directly access backend resources (database, file system)
- Can use
async/awaitat component level - Cannot use hooks, browser APIs, or event handlers
Client Components
- Add
'use client'directive at the top of the file - Required for interactivity, hooks, browser APIs
- Should be pushed down the component tree
// Server Component (default)
export default async function Page() {
const data = await fetchData() // Direct data fetching
return <div>{data.title}</div>
}
'use client'
// Client Component
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return <button => setCount(count + 1)}>{count}</button>
}
Data Fetching Patterns
In Server Components (Recommended)
async function getData() {
const res = await fetch('https://api.example.com/data', {
cache: 'force-cache', // default - caches indefinitely
// cache: 'no-store', // never cache
// next: { revalidate: 3600 }, // revalidate every hour
})
return res.json()
}
export default async function Page() {
const data = await getData()
return <main>{data.content}</main>
}
Server Actions (for mutations)
// app/actions.ts
'use server'
export async function createItem(formData: FormData) {
const name = formData.get('name')
await db.items.create({ data: { name } })
revalidatePath('/items')
}
Routing Hooks (Client Components Only)
Import from next/navigation:
'use client'
import { useRouter, usePathname, useSearchParams, useParams } from 'next/navigation'
export function Navigation() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const params = useParams()
// router.push('/dashboard')
// router.replace('/login')
// router.refresh()
// router.back()
// router.forward()
}
Dynamic Routes
app/
├── blog/
│ └── [slug]/ # Dynamic segment
│ └── page.tsx
├── shop/
│ └── [...slug]/ # Catch-all segment
│ └── page.tsx
└── docs/
└── [[...slug]]/ # Optional catch-all
└── page.tsx
// app/blog/[slug]/page.tsx
export default async function Page({
params
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return <article>Post: {slug}</article>
}
// Generate static paths
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post) => ({ slug: post.slug }))
}
Layouts and Templates
Root Layout (Required)
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
Nested Layouts
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<section>
<nav>Dashboard Nav</nav>
{children}
</section>
)
}
Loading and Error States
// app/dashboard/loading.tsx
export default function Loading() {
return <div>Loading...</div>
}
// app/dashboard/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button => reset()}>Try again</button>
</div>
)
}
Route Handlers (API Routes)
// app/api/items/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const query = searchParams.get('query')
const items = await getItems(query)
return NextResponse.json(items)
}
export async function POST(request: NextRequest) {
const body = await request.json()
const item = await createItem(body)
return NextResponse.json(item, { status: 201 })
}
Metadata
// Static metadata
export const metadata = {
title: 'My Page',
description: 'Page description',
}
// Dynamic metadata
export async function generateMetadata({ params }) {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.excerpt,
}
}
Production Patterns (MANDATORY)
These are NOT optional — apply them proactively on EVERY page you build.
1. Loading Skeletons (loading.tsx)
Every route that fetches data MUST have a loading.tsx with an animated skeleton matching the page layout:
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 w-48 bg-slate-200 rounded mb-4" />
<div className="grid grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-24 bg-slate-100 rounded-xl" />
))}
</div>
</div>
);
}
Never use a bare <div>Loading...</div>. Always use skeleton UI matching the actual page structure.
2. Suspense Boundaries (for partial loading)
When a page has a fast shell (header, nav, tabs) and slow content (external API calls), split them:
import { Suspense } from "react";
export default async function Page({ searchParams }) {
const client = await getClientFromDB(id); // FAST: DB only
return (
<>
<Header title={client.name} /> {/* Renders INSTANTLY */}
<TabNavigation activeTab={tab} /> {/* Renders INSTANTLY */}
<Suspense key={tab} fallback={<TabSkeleton />}>
<TabContent tab={tab} /> {/* Async: slow API calls */}
</Suspense>
</>
);
}
async function TabContent({ tab }) {
const data = await fetchExternalAPI(); // SLOW: 3-5 seconds
return <DisplayComponent data={data} />;
}
Rule: Before writing any page, ask "What's fast (DB) vs slow (external API)?" Put slow parts in Suspense.
The key={tab} prop is critical — it tells React to show the skeleton when the tab changes.
3. Cache External API Calls (unstable_cache)
NEVER call external APIs (Wix, HubSpot, OpenAI, Stripe, etc.) raw from page components. Always wrap with unstable_cache:
import { unstable_cache } from "next/cache";
// Cached wrapper — 60s TTL, tag-based invalidation
export function getCachedData(clientId: string) {
return unstable_cache(
() => fetchFromExternalAPI(clientId), // the actual slow call
[`api-data-${clientId}`], // cache key
{
revalidate: 60, // refresh every 60 seconds
tags: [`client-${clientId}`], // for manual invalidation
}
)();
}
In page components, use getCachedData() not fetchFromExternalAPI().
4. Invalidate Cache After Mutations (revalidateTag)
When a server action modifies data, invalidate related cache tags:
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function updateData(clientId: string) {
await saveToDatabase();
// Invalidate cached API data for this client
revalidateTag(`client-${clientId}`);
// Also revalidate the page path
revalidatePath(`/clients/${clientId}`);
}
Every server action that changes data should call both revalidateTag and revalidatePath.
5. Error Boundaries (error.tsx)
Every route should have error handling:
// app/dashboard/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="text-center py-16">
<h2 className="font-bold mb-2">Something went wrong</h2>
<p className="text-sm text-gray-500 mb-4">{error.message}</p>
<button => reset()} className="px-4 py-2 bg-primary text-white rounded-xl">
Try again
</button>
</div>
);
}
Decision Checklist (Before Writing Any Page)
Before creating or editing any page.tsx, answer these:
- Does this route have a
loading.tsx? If it fetches data, it needs one with a skeleton. - Are there fast parts and slow parts? Fast shell in the page, slow content in
<Suspense>. - Does it call external APIs? Wrap with
unstable_cache+ revalidation tags. - Do any actions mutate data? Add
revalidateTagafter mutations. - Is there an
error.tsx? Every route that fetches data needs error handling.
Best Practices
- Keep Client Components small - Push
'use client'as far down the tree as possible - Colocate data fetching - Fetch data in Server Components close to where it's used
- Use Server Actions for mutations instead of API routes when possible
- Cache external API calls - Always use
unstable_cachefor third-party APIs - Handle loading states - Skeleton UI in
loading.tsx+ Suspense for partial loading - Handle errors -
error.tsxin every route with data fetching - Invalidate on mutation -
revalidateTag+revalidatePathin every server action - Use route groups
(folder)for organization without affecting URL - Use parallel routes
@folderfor complex layouts - Split fast shell + slow content - Header/nav render instantly, data content in Suspense
Common Patterns
See the references/ directory for detailed patterns:
routing.md- Advanced routing patternsserver-components.md- Server component patternsdata-fetching.md- Data fetching strategies
See the examples/ directory for working code:
page-layout.tsx- Page and layout examplesserver-action.ts- Server action examplesroute-handler.ts- API route examples