Back to Blog
5 min read

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.

Next.js App Router — Patterns I Use in Every Project

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.