Next.js 13+ App Router conventions, Server vs Client Components, Server Actions, Middleware, data fetching, Metadata API, and route handlers. Use for Next.js App Router directory structure, Server Actions, SSR/RSC patterns, generateMetadata, layout/page/error/loading.tsx conventions, route groups, parallel routes, or hydration issues. Companion to react-dev and react-best-practices.
Install
npx skillscat add saajunaid/caddis-plugin/nextjs-app-router Install via the SkillsCat registry.
SKILL.md
Next.js App Router Skill
Covers Next.js 13+ App Router conventions. Companion to react-dev (typing) and react-best-practices (patterns). Always read those for React-level decisions.
1. File Conventions
app/
├── layout.tsx # Root shell — fonts, providers, global nav. Always present.
├── page.tsx # Default route segment UI
├── loading.tsx # Suspense boundary UI while segment loads
├── error.tsx # Error boundary ('use client' required)
├── not-found.tsx # 404 for segment
├── template.tsx # Re-mounts on navigation (vs layout which persists)
├── (marketing)/ # Route group — no URL segment, just org
│ ├── about/page.tsx
│ └── pricing/page.tsx
├── dashboard/
│ ├── layout.tsx # Nested layout (persists across dashboard routes)
│ └── settings/page.tsx
└── api/
└── webhook/route.ts # Route handlerKey Rules
error.tsxmust be'use client'— React error boundaries require it.loading.tsxwraps the segment'spage.tsxin<Suspense>automatically.template.tsxre-mounts children on every navigation; use only when fresh mount is needed.- Route groups
(group)do not affect URL paths — use for layout isolation or code organisation.
2. Server vs Client Components — Decision Tree
Does this component need:
- useState / useReducer? → 'use client'
- useEffect? → 'use client'
- Browser APIs (window, document)? → 'use client'
- Event listeners? → 'use client'
- Framer Motion / animation libs? → 'use client'
- Third-party client-side context? → 'use client'
Otherwise: → Server Component (default)Composition Pattern
// ✅ GOOD: Push 'use client' to the leaf
// app/dashboard/page.tsx — Server Component
import { InteractiveChart } from './interactive-chart'
export default async function DashboardPage() {
const data = await fetchData() // Direct async/await — no useEffect needed
return <InteractiveChart initialData={data} />
}
// interactive-chart.tsx
'use client'
export function InteractiveChart({ initialData }: { initialData: Data }) {
const [filter, setFilter] = useState('all')
// ...
}// ❌ BAD: Marking the whole page client just for one button
'use client'
export default async function Page() { // 'async' is invalid in client components
const data = await fetchData() // This won't work in client components
}3. Server Actions
Server Actions are async functions that run on the server, callable from Client Components.
// app/actions/user.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function updateUser(userId: string, formData: FormData) {
const name = formData.get('name') as string
await db.user.update({ where: { id: userId }, data: { name } })
revalidatePath('/dashboard/profile') // Revalidate specific path
}
export async function deletePost(postId: string) {
await db.post.delete({ where: { id: postId } })
revalidateTag('posts') // Revalidate by cache tag
}useActionState (React 19 / Next.js 14+)
'use client'
import { useActionState } from 'react'
import { updateUser } from '@/app/actions/user'
type State = { error?: string; success?: boolean }
export function ProfileForm({ userId }: { userId: string }) {
const [state, action, isPending] = useActionState<State, FormData>(
async (_, formData) => {
try {
await updateUser(userId, formData)
return { success: true }
} catch {
return { error: 'Update failed' }
}
},
{}
)
return (
<form action={action}>
<input name="name" disabled={isPending} />
{state.error && <p role="alert">{state.error}</p>}
<button type="submit" disabled={isPending}>{isPending ? 'Saving…' : 'Save'}</button>
</form>
)
}4. Middleware
// middleware.ts (at project root, NOT in app/)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Auth guard
const token = request.cookies.get('auth-token')?.value
if (pathname.startsWith('/dashboard') && !token) {
return NextResponse.redirect(new URL('/login', request.url))
}
// A/B test header
const response = NextResponse.next()
response.headers.set('x-variant', 'control')
return response
}
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
// Use edge runtime for simple string ops — avoids cold start
}5. Data Fetching
Server Component Fetch with Caching
// Cached fetch: deduplicated across the request lifecycle
async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`, {
next: { revalidate: 3600 }, // ISR: revalidate every hour
// next: { tags: ['user'] }, // Tag-based revalidation
// cache: 'no-store', // Dynamic — no cache
})
if (!res.ok) throw new Error('Failed to fetch')
return res.json()
}Parallel Data Fetching
// ✅ GOOD: Parallel — not waterfall
export default async function Page({ params }: { params: { id: string } }) {
const [user, posts, analytics] = await Promise.all([
getUser(params.id),
getPosts(params.id),
getAnalytics(params.id),
])
return <Dashboard user={user} posts={posts} analytics={analytics} />
}Suspense Streaming
import { Suspense } from 'react'
export default function Page() {
return (
<>
<Header />
<Suspense fallback={<Skeleton />}>
<AsyncDataTable /> {/* Streams in when ready */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<AsyncChart />
</Suspense>
</>
)
}6. Metadata API
// Static metadata
export const metadata = {
title: 'Dashboard',
description: 'Your personalised dashboard',
}
// Dynamic metadata
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
images: [{ url: post.coverImage, width: 1200, height: 630 }],
},
twitter: { card: 'summary_large_image', title: post.title },
}
}7. Route Handlers
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = Number(searchParams.get('page') ?? '1')
const users = await db.user.findMany({ skip: (page - 1) * 20, take: 20 })
return NextResponse.json({ users, page })
}
export async function POST(request: NextRequest) {
const body = await request.json()
// Validate with zod before DB write
const parsed = CreateUserSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 422 })
const user = await db.user.create({ data: parsed.data })
return NextResponse.json(user, { status: 201 })
}8. Common Pitfalls
| Pitfall | Cause | Fix |
|---|---|---|
| Hydration mismatch | Server HTML ≠ client render (dates, random IDs, browser-only APIs) | Wrap in <ClientOnly> component or use useEffect for client-only values |
'use client' creep |
Adding hooks to parent forces entire tree client | Push boundary to leaf — pass serialisable data as props |
| Nested layout confusion | Inner layout.tsx doesn't wrap sibling page.tsx at same level |
Layouts only wrap children segments, not siblings |
| Stale cache | fetch cached too aggressively |
Use revalidateTag on mutation, or cache: 'no-store' for real-time data |
| Server Action in Server Component | Calling a Server Action from a Server Component via form is fine; importing into client requires 'use server' import |
Declare 'use server' at top of action file, not per-function |
| Missing error handling in route handler | Unhandled error returns 500 with Next.js HTML error page | Always return NextResponse.json({ error: ... }, { status }) |
9. Quick Reference Checklist
-
error.tsxhas'use client'directive - No
async/awaitinside Client Components — lift to Server Component or Server Action - Parallel fetches use
Promise.all, not sequentialawait -
revalidatePathorrevalidateTagcalled after every mutating Server Action - Middleware
matcheris as specific as possible — avoid'/'matching everything -
generateMetadatadefined for every public-facing page - Route handlers validate input with zod before touching the database