Best practices for building Next.js apps with App Router, server components, server actions, and routing
Use Next.js App Router patterns for all new projects. Default to React Server Components (RSC) unless client interactivity is needed.
app/ directory for all routespage.tsx — route UI, always a Server Component by defaultlayout.tsx — shared UI wrapper, persists across navigationsloading.tsx — Suspense boundary UIerror.tsx — error boundary UI (must be 'use client')not-found.tsx — 404 UIroute.ts — API endpoint (replaces pages/api/)Default to Server Components. Add 'use client' only when you need:
useState, useEffect, or other React hookswindow, document)// Server Component (default) — no directive needed
async function Page() {
const data = await fetch('...') // direct async/await
return <div>{data}</div>
}
// Client Component
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
Fetch data in Server Components directly with async/await. Use cache and revalidate options:
// Cached (default)
const data = await fetch('/api/data')
// Revalidate every 60s
const data = await fetch('/api/data', { next: { revalidate: 60 } })
// No cache
const data = await fetch('/api/data', { cache: 'no-store' })
For database access, use server-only utilities:
import 'server-only'
import { db } from '@/lib/db'
export async function getUser(id: string) {
return db.user.findUnique({ where: { id } })
}
Use Server Actions for mutations. Define with 'use server' directive:
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
await db.post.create({ data: { title } })
revalidatePath('/posts')
}
// Usage in a Server Component form
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
app/
├── page.tsx → /
├── about/page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/page.tsx → /blog/:slug
├── (marketing)/ → route group, no URL segment
│ └── landing/page.tsx → /landing
└── @modal/ → parallel route slot
Dynamic params are typed via params prop:
export default function Post({ params }: { params: { slug: string } }) {
return <h1>{params.slug}</h1>
}
// Static
export const metadata = { title: 'My App' }
// Dynamic
export async function generateMetadata({ params }) {
const post = await getPost(params.slug)
return { title: post.title }
}