Next.js App Router — Patterns I Use in Every Project
Server Components, streaming, route handlers, and data fetching patterns that I've settled on after building multiple production apps with the Next.js App Router.
The App Router changes how you think about data fetching and component architecture. After building several production apps with it, here are the patterns I reach for by default.
Server Components Are the Default
The biggest mental shift: components are server-rendered by default. No client-side JS unless you opt in with "use client".
Rule I follow: push "use client" as far down the tree as possible.
// app/dashboard/page.tsx — Server Component
// No "use client" — runs on the server, zero client JS
import { db } from "@/lib/db"
export default async function DashboardPage() {
// Direct database access, no API round trip
const stats = await db.query.stats.findMany({
where: { userId: await getCurrentUserId() }
})
return (
<div>
<StatsGrid stats={stats} /> {/* Server Component */}
<RealtimeChart /> {/* Client Component — only this ships JS */}
</div>
)
}This dramatically reduces your JS bundle. An entire analytics dashboard can render with near-zero client JavaScript if you keep state and interactivity confined to leaf components.
Parallel Data Fetching
Avoid the waterfall. Fetch independent data in parallel:
// Bad — sequential waterfall
const user = await getUser(id)
const posts = await getPosts(id) // waits for user
const stats = await getStats(id) // waits for posts
// Good — parallel
const [user, posts, stats] = await Promise.all([
getUser(id),
getPosts(id),
getStats(id),
])For Suspense-based streaming:
import { Suspense } from "react"
export default function ProfilePage({ params }: { params: { id: string } }) {
return (
<div>
<ProfileHeader id={params.id} />
<Suspense fallback={<PostsSkeleton />}>
<UserPosts id={params.id} />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<UserStats id={params.id} />
</Suspense>
</div>
)
}ProfileHeader renders immediately. UserPosts and UserStats stream in as they resolve — independently. The page is interactive before all data loads.
Server Actions for Mutations
Route handlers (/api/*) are still useful for webhooks and external integrations, but for form submissions and mutations from the client, Server Actions are cleaner:
// app/actions/createProject.ts
"use server"
import { revalidatePath } from "next/cache"
import { z } from "zod"
const CreateProjectSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
})
export async function createProject(formData: FormData) {
const session = await getServerSession()
if (!session) throw new Error("Unauthorized")
const data = CreateProjectSchema.parse({
name: formData.get("name"),
description: formData.get("description"),
})
await db.project.create({
data: { ...data, userId: session.user.id }
})
revalidatePath("/dashboard/projects")
}// Client Component
"use client"
import { createProject } from "@/app/actions/createProject"
import { useTransition } from "react"
export function CreateProjectForm() {
const [isPending, startTransition] = useTransition()
return (
<form action={createProject}>
<input name="name" required />
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create Project"}
</button>
</form>
)
}No API route needed. The Server Action runs on the server, validates input, mutates the DB, and revalidates the cache.
Caching Strategy
Next.js 14+ has explicit cache semantics. I don't rely on defaults:
// Force fresh data — bypasses all caching
const data = await fetch(url, { cache: "no-store" })
// Cache for 60 seconds (ISR-style)
const data = await fetch(url, { next: { revalidate: 60 } })
// Cache until explicitly invalidated
const data = await fetch(url, { next: { tags: ["projects"] } })
// Invalidate from a Server Action
import { revalidateTag } from "next/cache"
revalidateTag("projects")For database queries (not using fetch), use unstable_cache:
import { unstable_cache } from "next/cache"
const getProjectStats = unstable_cache(
async (userId: string) => {
return db.project.count({ where: { userId } })
},
["project-stats"],
{ revalidate: 300, tags: ["projects"] }
)Error Boundaries Per Segment
Each route segment can have its own error.tsx:
// app/dashboard/projects/error.tsx
"use client"
export default function ProjectsError({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div>
<p>Failed to load projects: {error.message}</p>
<button onClick={reset}>Retry</button>
</div>
)
}This error boundary only catches errors within the projects segment — the rest of the dashboard stays functional.
Metadata Per Page
// app/blog/[slug]/page.tsx
import { type Metadata } from "next"
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
images: [{ url: post.image }],
},
}
}Dynamic metadata without a separate API call — the data is fetched once and Next.js deduplicates the request.
The App Router has a learning curve, but once these patterns click, it's hard to go back to the Pages Router.