Next.js Best Practices Skill
Apply these rules when writing or reviewing Next.js code. Covers App Router (Next.js 13+).
1. File Conventions
Project Structure
app/
├── layout.tsx # Root layout (required)
├── page.tsx # Home page
├── loading.tsx # Loading UI (Suspense boundary)
├── error.tsx # Error boundary
├── not-found.tsx # 404 page
├── global-error.tsx # Root-level error boundary
├── (auth)/ # Route group (no URL segment)
│ ├── login/page.tsx
│ └── register/page.tsx
├── [id]/ # Dynamic segment
│ └── page.tsx
├── [...slug]/ # Catch-all segment
└── api/
└── route.ts # Route Handler
Special Files
| File | Purpose |
|---|---|
layout.tsx |
Shared UI, persists across navigations |
page.tsx |
Unique page UI, makes route publicly accessible |
loading.tsx |
Auto-wraps page in <Suspense> |
error.tsx |
Error boundary for segment (client component) |
not-found.tsx |
Rendered when notFound() is thrown |
route.ts |
API endpoint (no co-located page.tsx allowed) |
middleware.ts |
Runs before request, at edge |
2. React Server Components (RSC) Boundaries
Server Components (default) - do:
async/awaitdata fetching directly in component- Access databases, file system, environment variables
- Import server-only packages
Client Components ('use client') - required when:
- Using
useState,useEffect,useReducer,useContext - Using browser APIs (
window,document,localStorage) - Using event handlers (
onClick,onChange) - Using third-party client libraries
Rules
- Never make a Client Component
async- it's invalid - Never pass non-serializable props (functions, class instances) from Server → Client
- Server Components can import Client Components, not the reverse
- Use
'use server'only for Server Actions, not to mark Server Components
3. Async APIs - Next.js 15+
In Next.js 15, params, searchParams, cookies(), headers(), and draftMode() are now async. Always await them:
// ✅ Correct - Next.js 15+
export default async function Page({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ q: string }>
}) {
const { id } = await params
const { q } = await searchParams
const cookieStore = await cookies()
const token = cookieStore.get('token')
// ...
}
// ❌ Wrong - old synchronous pattern
export default function Page({ params }: { params: { id: string } }) {
const { id } = params // broken in Next.js 15
}
4. Data Fetching Patterns
Server Components - preferred for most fetching
async function ProductList() {
const products = await db.products.findMany() // Direct DB access, no API needed
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}
Avoid waterfalls - use Promise.all
// ❌ Sequential waterfall - slow
const user = await getUser(id)
const posts = await getPosts(user.id)
// ✅ Parallel - fast
const [user, posts] = await Promise.all([getUser(id), getPosts(id)])
Server Actions - for mutations
// app/actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
await db.posts.create({ data: { title } })
revalidatePath('/posts')
}
// app/page.tsx
import { createPost } from './actions'
export default function Page() {
return (
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
)
}
Route Handlers - for API endpoints needed by external clients
// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const products = await db.products.findMany()
return NextResponse.json(products)
}
export async function POST(request: NextRequest) {
const body = await request.json()
const product = await db.products.create({ data: body })
return NextResponse.json(product, { status: 201 })
}
5. Image Optimization
Always use next/image instead of <img>:
import Image from 'next/image'
// Local image - size known automatically
import photo from './photo.jpg'
<Image src={photo} alt="Description" />
// Remote image - must configure domain in next.config.ts
<Image
src="https://example.com/photo.jpg"
alt="Description"
width={800}
height={600}
sizes="(max-width: 768px) 100vw, 50vw"
priority // Add for LCP images above the fold
/>
Configure remote domains:
// next.config.ts
const config: NextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'example.com' },
],
},
}
6. Font Optimization
Always use next/font instead of <link> tags - it prevents layout shift and self-hosts fonts:
// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})
export default function RootLayout({ children }) {
return (
<html className={`${inter.variable}`}>
<body>{children}</body>
</html>
)
}
7. Error Handling
// app/error.tsx - must be a Client Component
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button again</button>
</div>
)
}
Trigger programmatically:
import { notFound, redirect } from 'next/navigation'
// In any Server Component or Server Action:
if (!user) notFound() // renders not-found.tsx
if (!session) redirect('/login') // HTTP 307 redirect
8. Metadata
// app/layout.tsx - static
export const metadata = {
title: { template: '%s | My App', default: 'My App' },
description: 'My application description',
}
// app/products/[id]/page.tsx - dynamic
export async function generateMetadata({ params }) {
const { id } = await params
const product = await getProduct(id)
return {
title: product.name,
openGraph: { images: [product.imageUrl] },
}
}
9. Suspense Boundaries
Wrap anything that uses useSearchParams() or usePathname() in a Client Component with <Suspense> to avoid CSR bailout:
import { Suspense } from 'react'
import SearchBar from './SearchBar' // contains useSearchParams
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SearchBar />
</Suspense>
)
}
10. Self-Hosting with Docker
// next.config.ts
const config: NextConfig = {
output: 'standalone',
}
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=base /app/.next/standalone ./
COPY --from=base /app/.next/static ./.next/static
COPY --from=base /app/public ./public
CMD ["node", "server.js"]
Common Mistakes
- Async Client Components - invalid, move
asyncto a parent Server Component paramsaccess withoutawaitin Next.js 15+ - addawait params<img>instead ofnext/image- always usenext/imagefor optimization<link>tags for fonts - always usenext/font- Fetching in Client Components when a Server Component would work - default to Server
- Route Handler +
page.tsxin the same folder - causes a conflict, use different paths export const dynamic = 'force-dynamic'everywhere - use it only when truly needed