Next.js Patterns
App Router Structure
app/
├── layout.tsx # Root layout (Server Component)
├── page.tsx # Home route
├── loading.tsx # Suspense boundary
├── error.tsx # Error boundary ('use client')
├── not-found.tsx # 404 UI
├── (marketing)/ # Route group — no URL segment
│ ├── about/page.tsx
│ └── blog/[slug]/page.tsx
├── (app)/ # Auth-protected group
│ └── dashboard/
│ ├── layout.tsx
│ └── page.tsx
└── api/
└── webhooks/route.ts
Server vs Client Components
// Server Component (default) — async/await, direct DB access
async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.product.findUnique({ where: { id: params.id } })
return <ProductDetail product={product} />
}
// Client Component
'use client'
function AddToCart({ productId }: { productId: string }) {
const [loading, setLoading] = useState(false)
// event handlers, browser APIs, hooks work here
}
Data Fetching
// ISR — revalidate every hour
const res = await fetch(url, { next: { revalidate: 3600 } })
// No cache (dynamic)
const res = await fetch(url, { cache: 'no-store' })
// Tag-based revalidation
const res = await fetch(url, { next: { tags: ['posts'] } })
// Trigger: revalidateTag('posts') from a Server Action
Route Handlers
// app/api/users/route.ts
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)
}
export async function POST(request: NextRequest) {
const body = await request.json()
const user = await db.user.create({ data: body })
return NextResponse.json(user, { status: 201 })
}
// Dynamic segment: app/api/users/[id]/route.ts
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
const user = await db.user.findUnique({ where: { id: params.id } })
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(user)
}
Metadata API
// Static
export const metadata: Metadata = {
title: 'My App',
description: 'Description',
openGraph: { title: 'My App', images: ['/og.png'] },
}
// Dynamic
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const post = await getPost(params.slug)
return { title: post.title, openGraph: { images: [post.image] } }
}
Middleware
// middleware.ts
export function middleware(request: NextRequest) {
const token = request.cookies.get('token')?.value
if (request.nextUrl.pathname.startsWith('/dashboard') && !token) {
return NextResponse.redirect(new URL('/login', request.url))
}
const response = NextResponse.next()
response.headers.set('x-request-id', crypto.randomUUID())
return response
}
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
}
generateStaticParams
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map(post => ({ slug: post.slug }))
}
Loading UI & Streaming
// app/dashboard/loading.tsx — auto-wraps page in Suspense
export default function Loading() { return <DashboardSkeleton /> }
// Manual Suspense for granular streaming
export default async function Page() {
return (
<div>
<StaticHeader />
<Suspense fallback={<Skeleton />}>
<SlowComponent />
</Suspense>
</div>
)
}
Image & Font Optimization
import Image from 'next/image'
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'], display: 'swap' })
<Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority />
Environment Variables
DATABASE_URL=postgres://... # server-only
NEXT_PUBLIC_API_URL=https://api... # exposed to client (NEXT_PUBLIC_ prefix)