Next.js App Router went stable in version 13.4, back in May 2023. A year later, with Next.js 14.2 as the mainline, teams that took the leap have concrete lessons to share. Some cheerful, some painful, most absent from the official docs. This article collects what we keep seeing in real projects: what pays off, what costs, and how to approach the transition without torching the team’s confidence in the framework.
A Model Shift, Not a Syntax Shift
The initial temptation is to treat App Router as “Pages Router with a different folder layout”. That’s an expensive mistake. The new router changes the mental model of the entire application. Components run on the server by default, data fetching happens with plain async/await directly inside the render tree, layouts nest by directory structure, and caching goes from being explicit to being a layered system you have to understand.
File conventions (page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx, route.ts) replace pages/* and getServerSideProps. Each file with a reserved name has a specific contract with the framework: layout.tsx wraps all routes in its subtree while preserving state across navigations, loading.tsx generates an automatic Suspense boundary, error.tsx acts as an error boundary. They aren’t optional imports, they’re hooks into the routing system.
Natural fetching is probably the best part. Gone are getServerSideProps and SWR or React Query hooks for the common server-side case. An async component resolves its own data and the framework handles streaming:
// app/users/[id]/page.tsx
export default async function UserPage({ params }: { params: { id: string } }) {
const user = await db.user.findUnique({ where: { id: params.id } });
const posts = fetchPosts(params.id); // no await: resolves inside Suspense
return (
<section>
<UserCard user={user} />
<Suspense fallback={<PostsSkeleton />}>
<PostsList promise={posts} />
</Suspense>
</section>
);
}
A lot has been written about Server Components, but the practical nuance is this: they remove JavaScript from the client for anything that doesn’t need interactivity. On a typical detail page (header, content, footer, a handful of interactive widgets), the bundle shipped to the browser easily drops 30-40% compared to the same page on Pages Router. That translates into lower TTI, especially on mid-range mobile devices.
What Really Hurts
Caching is the biggest headache of the first month. Next.js caches aggressively by default: fetch results, full pages, router segments. The typical outcome is that an endpoint that used to return fresh data now returns something from hours ago and nobody understands why. There are four cache layers (Request Memoization, Data Cache, Full Route Cache, Router Cache) and each has its own invalidation mechanism. The cache: 'no-store' flag on fetch, export const revalidate = 60, export const dynamic = 'force-dynamic', and calls to revalidatePath() or revalidateTag() from Server Actions are different tools for different problems. Learning which one to use when takes weeks.
The boundary between Server Components and Client Components is the second source of friction. Using useState or useEffect inside a server component throws a clear error, but silent failures are worse: a library that conditionally imports something from window, a context provider that breaks during hydration, a third-party hook that depends on document. The 'use client' directive marks a component and all its descendants as client code; placing it too high in the tree cancels most of the Server Components benefits.
The errors that hurt most are hydration mismatches. A Server Component rendering a date formatted with the server’s timezone and a Client Component that, upon hydration, formats with the browser’s timezone produces a mismatch that React detects and reports with a vague message. You have to think explicitly about which data is stable across server and client.
The ecosystem has been adapting. Material-UI, Chakra UI, Emotion, and other CSS-in-JS libraries have published specific guides for App Router, usually wrapping the app in a provider marked as a Client Component. Tailwind, being compiled, doesn’t suffer the issue. Form libraries like React Hook Form work but require 'use client' in the form components.
Server Actions deserve a separate mention. They’re mutations written as async functions marked with 'use server' and passed directly to the action attribute of a <form>. They remove the need to create API routes for simple operations, integrate progressive enhancement (the form works without JavaScript), and combine with revalidatePath to invalidate caches after a write. In projects with simple mutations (create, update, delete), they cut boilerplate noticeably. In complex flows with intricate validation, optimistic updates, and retries, explicit endpoints or libraries like tRPC are still preferable.
Migrating Without Stopping the Machine
Next.js supports coexistence: app/ and pages/ live in the same project. The realistic strategy for any production application is incremental. Existing routes stay on Pages Router, new features are written in App Router, and old routes are migrated one at a time when there’s a reason (a UI rewrite, a bug that justifies touching the file, a data refactor). For a mid-size project, 30 to 60 routes, expect between two and six months of actual migration work, not of calendar time.
The pattern that works best: start with read-only routes (lists, detail pages, landings), leave for last the ones with complex client logic (interactive dashboards, editors, multi-step wizards). Read-only routes benefit immediately from Server Components; interactive ones require more refactoring and the benefit is smaller.
A common mistake is migrating “because you have to” without a plan. A small team with a tight deadline and a product that works on Pages Router gains nothing from migrating now. Vercel has confirmed Pages Router will keep receiving maintenance indefinitely and plenty of large apps in the ecosystem remain on it by deliberate choice.
Deployment and Performance
On Vercel, deployment is transparent, as expected. Outside Vercel there are two paths: self-hosting with output: 'standalone' on Node.js inside a container (the most predictable option), or the OpenNext adapter for Lambda when genuine serverless is the goal. Cloudflare Pages still doesn’t support every App Router feature; check the compatibility table before committing.
The numbers we’ve measured in real migrations: TTFB typically improves 30-50% thanks to streaming; the client bundle shrinks 30-45% when Server Components are used well; LCP and FCP improve visibly on pages with data fetching. On the downside: builds take longer, server CPU usage goes up because now it renders more, and cold starts in serverless environments are heavier.
App Router is a significant leap but not a free one. The architectural advantages are real and measurable, and the Server Components model points where React is heading in general. The costs (learning curve, subtle caching, an ecosystem still adapting) are also real and have to be factored into planning. For new projects in 2024, the recommendation is clear: start directly with App Router. For established projects on Pages Router, migrate with care, route by route, without rushing and with metrics justifying each step. The technical debt of not migrating doesn’t exist yet; the debt of migrating badly does.