The Next.js App Router has been generally available for over two years now. The early "should we migrate?" debates have settled. the answer for new projects is yes, and the migration patterns for existing apps are mature. But the App Router rewards a different mental model than the Pages Router, and getting it wrong leads to apps that are slower than the framework they replaced.
This article is the production playbook: the patterns that hold up at scale, the gotchas that bite teams in their first month, and the architectural decisions that compound over time.
The Mental Model Shift
The single biggest mistake teams make migrating to the App Router is treating Server Components as a deployment target rather than a default. In the Pages Router, every component runs on both server and client; you opt out with getStaticProps or getServerSideProps. In the App Router, every component is a Server Component by default and you opt into client behaviour with "use client".
This inversion changes what "fast" means:
- Server Components ship zero JavaScript to the client. The HTML is rendered on the server (or at build time) and sent as a stream.
- Client Components ship to the browser as JavaScript bundles. Hydration is required.
Performance optimisation in the App Router becomes a question of: what is the smallest possible client boundary I can draw around the interactive part?
// app/products/[id]/page.tsx - Server Component (default)
import { db } from "@/lib/db";
import { AddToCartButton } from "./add-to-cart-button";
export default async function ProductPage({
params,
}: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await db.products.findUnique({ where: { id } });
if (!product) notFound();
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<AddToCartButton productId={product.id} />
</article>
);
}
// app/products/[id]/add-to-cart-button.tsx - only this is client JS
"use client";
import { useState } from "react";
export function AddToCartButton({ productId }: { productId: string }) {
const [pending, setPending] = useState(false);
return (
<button
disabled={pending}
onClick={async () => {
setPending(true);
await fetch("/api/cart", { method: "POST", body: JSON.stringify({ productId }) });
setPending(false);
}}
>
{pending ? "Adding..." : "Add to cart"}
</button>
);
}
The page is rendered server-side from the database. Only the button. the part that needs interactivity. ships JavaScript. This is the foundational pattern.
Streaming with Suspense
The App Router's streaming model is its most underused superpower. With Suspense boundaries, you can ship a page's shell immediately and fill in slow parts as their data arrives.
// app/dashboard/page.tsx
import { Suspense } from "react";
import { RevenueChart, RevenueChartSkeleton } from "./revenue-chart";
import { RecentOrders, RecentOrdersSkeleton } from "./recent-orders";
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<RecentOrdersSkeleton />}>
<RecentOrders />
</Suspense>
</main>
);
}
If RevenueChart queries a slow data warehouse and RecentOrders queries a fast OLTP database, the page's first byte arrives immediately. Recent orders fill in within 50ms. The chart fills in when its query completes. without blocking anything else.
The trap to avoid: don't await data outside a Suspense boundary unless you want it to block the entire response. Top-level await in your page component blocks streaming.
Parallel Routes for Complex Layouts
Parallel routes solve the "modal that needs its own URL", "sidebar that loads independently", and "settings page with multiple panels" problems cleanly.
app/
├── @analytics/
│ └── page.tsx
├── @team/
│ └── page.tsx
├── layout.tsx // receives `analytics` and `team` as props
└── page.tsx
// app/layout.tsx
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div className="grid grid-cols-3 gap-4">
<main className="col-span-2">{children}</main>
<aside>{analytics}{team}</aside>
</div>
);
}
Each slot has its own loading, error, and not-found states. Each streams independently. For dashboard-style apps with multiple data-fetching panels, this is the right primitive.
Server Actions Without the Footguns
Server Actions are the App Router's answer to API routes for mutations. They eliminate boilerplate, but the patterns that ship to production look different from the marketing demos.
// app/posts/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { z } from "zod";
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1).max(50_000),
});
export async function createPost(formData: FormData) {
const user = await auth();
if (!user) throw new Error("Unauthorised");
const parsed = CreatePostSchema.safeParse({
title: formData.get("title"),
body: formData.get("body"),
});
if (!parsed.success) {
return { error: parsed.error.flatten() };
}
const post = await db.posts.create({
data: { ...parsed.data, authorId: user.id },
});
revalidatePath("/posts");
return { post };
}
Three rules I enforce on every codebase:
- Always validate inputs at the action boundary. A Server Action is an API endpoint. anyone with the form's URL can call it with arbitrary data.
- Always check auth at the action boundary. Don't trust the client.
- Return errors as values, throw for unexpected failures. This pattern plays well with
useActionStateand gives you typed error handling.
Migration Advice From Pages Router
If you're migrating an existing app, don't do it page-by-page in the same project unless you absolutely have to. The two routers can coexist, but they have subtly different caching behaviour, and debugging "why is this hydrating differently in /old-page vs /new-page" eats weeks.
The cleaner migration is: build a new App Router project alongside, route traffic with a reverse proxy, and migrate route-by-route at the load balancer. Production-safe, no big-bang risk, no shared-codebase headaches.
What the App Router is Good For (and What it Isn't)
The App Router is the right choice for content sites, marketing sites, dashboards, e-commerce, and most CRUD applications. The Server Component default is a meaningful performance win for these.
It's a worse choice for highly interactive single-page apps where most of the UI runs client-side anyway. If 90% of your component tree needs "use client", you're paying the App Router's complexity tax for a Pages Router benefit. Pick the right tool.