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/imagewith explicitsizes? - Is the client bundle as small as possible (check
next buildoutput)? - 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.