Back to blog
Next.jsReactPerformance

Next.js App Router: Everything We've Learned After 12 Months of Production Use

Sam PatelFebruary 20, 20254 min read

A Year in the Trenches

The Next.js App Router landed with a lot of fanfare — and a lot of confusion. Server Components, Client Components, use client, use server — there was a lot to absorb.

After migrating five production apps and building three new ones from scratch on the App Router, we have opinions. Strong ones.

The Mental Model That Changed Everything

The key insight that made everything click: think in terms of where code runs, not just what it does.

Server Component     → runs on server, never ships to browser
Client Component     → runs in browser (and optionally SSR'd once)
Server Action        → function that runs on server, called from client

Once you internalize this, the use client directive stops feeling arbitrary. You only reach for it when you need:

  • useState or useEffect
  • Browser APIs (window, document, etc.)
  • Event listeners
  • Third-party libraries that need the DOM

Everything else? Keep it on the server.

Patterns We Use Everywhere

1. Async Server Components for Data Fetching

// app/dashboard/page.tsx
export default async function DashboardPage() {
  const data = await fetchUserData(); // No useEffect, no loading states
  return <Dashboard data={data} />;
}

This is the pattern we reach for first. No useEffect, no loading states, no client-side data fetching overhead. The data is fetched and the HTML is streamed.

2. Suspense for Progressive Loading

import { Suspense } from "react";

export default function Page() {
  return (
    <div>
      <HeroSection /> {/* Renders immediately */}
      <Suspense fallback={<AnalyticsSkeleton />}>
        <AnalyticsPanel /> {/* Streams in when ready */}
      </Suspense>
    </div>
  );
}

3. The "Islands" Pattern for Interactivity

Keep pages mostly Server Components, sprinkle in Client Components only where needed:

// 95% of this is server-rendered
export default async function BlogPost({ params }) {
  const post = await getPost(params.slug);
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
      <ShareButtons url={post.url} /> {/* "use client" — needs browser APIs */}
      <CommentSection postId={post.id} /> {/* "use client" — interactive */}
    </article>
  );
}

The Gotchas We Hit

Context Doesn't Work Across the Boundary

You cannot pass context from a Server Component to a Client Component. Instead, pass data as props and create context providers inside Client Components.

Cookies and Headers Are Async in Next.js 15

This tripped us up on several projects. In Next.js 15, cookies() and headers() are now async:

// Next.js 15 — must await
import { cookies } from "next/headers";

export default async function Page() {
  const cookieStore = await cookies();
  const token = cookieStore.get("token");
  // ...
}

Don't Over-Componentize for the Sake of It

We see a lot of codebases that have tiny Server Components with single Client Component children. If a component needs interactivity, just make the whole thing a Client Component. The DX cost of splitting isn't always worth the bundle savings.

Performance Results

Across our client projects that migrated from Pages Router to App Router:

Metric Before After Delta
LCP 2.8s 1.4s -50%
TTI 4.2s 2.1s -50%
JS Bundle 340kb 190kb -44%

These aren't cherry-picked numbers — they're averages across five production apps. The results speak for themselves.

Our Recommendation

If you're starting a new Next.js project today: start with the App Router. The learning curve is real but front-loaded. Once it clicks, you'll never want to go back.

If you're migrating an existing Pages Router app: migrate incrementally. The two routers can coexist, so you can move route by route without a big-bang rewrite.

Have questions about the App Router? Reach out — we love talking shop.