Next.js App Router: Lessons from Migrating Real Projects
Updated: 2026-07-12
App Router went stable in Next.js 13.4 (May 2023), bringing Server Components, layered caching, and Server Actions, but it demands rethinking the application's mental model. Migrating from Pages Router pays off for new projects and read-only routes; for large applications, the realistic path is incremental, route by route, over several months.
Next.js App Router went stable in version 13.4[1], 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.
Key takeaways
-
App Router is a mental model shift, not a syntax shift: components are server-side by default.
-
Layered caching (Request Memoization, Data Cache, Full Route Cache, Router Cache) is the main source of confusion in the first month.
-
The Server Component / Client Component boundary and
'use client'directive should sit as low in the tree as possible. -
Server Actions eliminate API routes for simple operations; for complex flows with optimistic updates, tRPC or explicit endpoints are still better.
-
For established Pages Router projects: incremental migration route by route, not a big bang. Pages Router will receive maintenance indefinitely.
A model shift, not a syntax shift
Treating App Router as "Pages Router with a different folder layout" is 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/awaitdirectly in the render tree. -
Layouts nest by directory structure and preserve state across navigations.
-
Caching stops being explicit and becomes a layered system you must understand before debugging.
File conventions (page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx, route.ts) replace pages/* and getServerSideProps. They aren’t optional imports: they’re hooks into the routing system with specific contracts.
Natural data fetching is probably the best part:
// 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>
);
}
On a typical detail page, the client bundle sent to the browser easily drops 30-40% compared to Pages Router thanks to Server Components. 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, whole pages, router segments. The typical outcome: an endpoint that used to return fresh data now returns something hours old. There are four cache layers, each with its own invalidation mechanism:
-
cache: 'no-store'infetchfor always-fresh data. -
export const revalidate = 60for time-based revalidation. -
export const dynamic = 'force-dynamic'for routes with no cache. -
revalidatePath()orrevalidateTag()from Server Actions for explicit invalidation.
Learning which one to use when takes weeks of real production experience.
The Server / Client Component boundary is the second source of friction. The 'use client' directive marks a component and all its descendants as client code; placing it too high in the tree cancels out most of the benefits of Server Components. CSS-in-JS libraries (Material-UI, Chakra, Emotion) require providers marked as Client Components. Tailwind, being compiled, doesn’t suffer the problem.
Hydration mismatches are the most painful errors: a Server Component that renders a date with the server’s timezone and a Client Component that formats it with the browser’s timezone on hydration produce a mismatch that React flags with a vague message. You need to think explicitly about which data is stable between server and client.
Server Actions
Server Actions are mutations written as async functions marked with 'use server' and passed to a <form>‘s action attribute. They remove the need to create API routes for simple operations, they integrate progressive enhancement, and they pair with revalidatePath to invalidate cache after a write.
In projects with simple mutations (create, update, delete), they cut boilerplate considerably. In complex flows with intricate validation, optimistic updates, and retries, explicit endpoints or libraries like tRPC are still preferable. It fits well with frameworks like Remix v2, which also bets on form-based mutations.
Migrating without stopping the machine
Next.js allows coexistence: app/ and pages/ live in the same project. The realistic strategy for any application already in production is incremental:
-
Keep existing routes on Pages Router.
-
Write new features on App Router.
-
Migrate old routes when there’s a reason to (a UI rewrite, a bug that justifies touching the file).
For a mid-size project with 30-60 routes, expect two to six months of real migration work. The pattern that works best: start with read-only routes (listings, detail pages, landings) and leave routes with complex client logic for last (interactive dashboards, editors, multi-step wizards).
A common mistake is migrating "because you have to" without a plan. Next.js’s official migration guide[2] confirms that Pages Router will keep receiving maintenance indefinitely. The technical debt of not migrating doesn’t exist yet; the debt of migrating badly does.
Deployment and performance
On Vercel, deployment is transparent. Outside Vercel there are two paths: self-hosting with output: 'standalone' on Node.js in a container (the most predictable option), or the OpenNext adapter[3] for Lambda. On the other edge platform we’ve already covered in detail, Cloudflare Workers: Cloudflare Pages still doesn’t support every App Router feature, per its own compatibility table[4]; check it before committing.
Numbers measured in real migrations:
-
TTFB typically improves 30-50% thanks to streaming.
-
Client bundle size shrinks 30-45% with good use of Server Components.
-
LCP and FCP improve visibly on pages with data fetching.
-
Builds take longer and server CPU usage goes up because it now renders more.
App Router is a significant leap but not a free one. For new projects, the recommendation is to start directly with App Router. For established Pages Router projects, migrate deliberately, route by route, without rushing, and with metrics that justify each step.
Prefer to read in Spanish? Full version: Next.js App Router: lecciones de migrar proyectos reales.