Back to Blog
Next.jsReactTypeScriptPerformanceArchitecture

Next.js 15 App Router: Patterns That Actually Work in Production

After migrating three production apps to Next.js 15, here are the App Router patterns, pitfalls, and performance tricks I wish I'd known at the start.

January 20, 20253 min read

The Mental Model Shift

The App Router isn't just a routing upgrade. It's a fundamentally different execution model. Every component is a React Server Component by default. Client interactivity is opt-in via "use client".

The heuristic I use: push the boundary as deep as possible.

app/
  page.tsx          ← Server Component (data fetching, SEO)
  layout.tsx        ← Server Component (shell, metadata)
  components/
    DataTable.tsx   ← Server Component (renders rows)
    SortButton.tsx  ← "use client" (needs onClick)

Data Fetching Patterns

1. Parallel fetching with Promise.all

Avoid waterfall fetches. Kick off independent requests simultaneously:

// app/dashboard/page.tsx
async function DashboardPage() {
  const [user, metrics, posts] = await Promise.all([
    fetchUser(),
    fetchMetrics(),
    fetchRecentPosts(),
  ]);

  return <Dashboard user={user} metrics={metrics} posts={posts} />;
}

2. Streaming with Suspense

Keep the shell rendering immediately. Stream heavy data in lazily:

import { Suspense } from "react";

export default function Page() {
  return (
    <>
      <HeroSection />
      <Suspense fallback={<MetricsSkeleton />}>
        <SlowMetrics />   {/* resolves independently */}
      </Suspense>
    </>
  );
}

3. Server Actions for mutations

Replace API routes for simple mutations. The DX is dramatically cleaner:

"use server";

export async function updateProfile(formData: FormData) {
  const name = formData.get("name") as string;
  await db.user.update({ where: { id: session.userId }, data: { name } });
  revalidatePath("/profile");
}

The Server / Client Boundary Rule

The most common mistake I see: importing a client component into a server component via an intermediate module that accidentally pulls in client-only code.

The fix: explicit boundary files:

// components/Chart.client.tsx  ← naming convention makes intent clear
"use client";
import { LineChart } from "recharts";
// ...

Caching Strategy

Next.js 15 changed the caching defaults significantly. Fetch is no longer cached by default. Explicit is better:

// Cached at build time
const data = await fetch(url, { cache: "force-cache" });

// Revalidated every 60 seconds
const data = await fetch(url, { next: { revalidate: 60 } });

// Always fresh (equivalent to SSR)
const data = await fetch(url, { cache: "no-store" });

TypeScript Tips for App Router

Strongly type your params and searchParams. Next.js 15 ships proper types:

interface PageProps {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ page?: string; filter?: string }>;
}

export default async function Page({ params, searchParams }: PageProps) {
  const { slug } = await params;
  const { page = "1" } = await searchParams;
  // ...
}

Performance Checklist

Before shipping any App Router page, I run through:

  • Are all data fetches parallel (no await-then-await chains)?
  • Is the largest Contentful Paint element above the fold?
  • Are images using next/image with explicit sizes?
  • Is the client bundle as small as possible (check next build output)?
  • Are Server Components used for everything that doesn't need interactivity?

App Router is genuinely excellent once the mental model clicks. The key is treating SSR/SSG/ISR not as modes but as per-route caching decisions.