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/await directly 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 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' in fetch for always-fresh data.

  • export const revalidate = 60 for time-based revalidation.

  • export const dynamic = 'force-dynamic' for routes with no cache.

  • revalidatePath() or revalidateTag() 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 renders a date with the server’s timezone; a Client Component formats it with the browser’s timezone on hydration. The result is 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 replace an API route with a function. 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 improves by 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.

Sources:

  1. App Router Update: Next.js 13.4 stability announcement[1]
  2. Official Pages Router to App Router migration guide (Next.js)[2]
  3. Cloudflare Pages: supported Next.js features[4]
  4. OpenNext: AWS Lambda adapter documentation[3]

Frequently asked questions

Do I have to migrate from Pages Router to App Router right away?

No. The official migration guide confirms Pages Router will keep receiving maintenance indefinitely, and app/ and pages/ coexist in the same project. The realistic strategy is incremental: keep existing routes, write new features on App Router and migrate old routes only when there is a reason to. For a mid-size project with 30-60 routes, expect two to six months of real migration work.

Why does my endpoint return hours-old data after moving to App Router?

Because Next.js caches aggressively by default: fetch results, whole pages and router segments. There are four cache layers, each with its own invalidation: cache: 'no-store' in fetch for always-fresh data, export const revalidate = 60 for time-based revalidation, export const dynamic = 'force-dynamic' for routes with no cache, and revalidatePath() or revalidateTag() from Server Actions for explicit invalidation.

Where should the 'use client' directive go so I keep the Server Component benefits?

As low in the tree as possible. 'use client' marks a component and all its descendants as client code, so placing it too high cancels out most of the bundle savings, which on a typical detail page reach 30-40%. CSS-in-JS libraries such as Material-UI, Chakra or Emotion require providers marked as Client Components; Tailwind, being compiled, does not have that problem.

Sources

  1. went stable in version 13.4
  2. official migration guide
  3. OpenNext adapter
  4. compatibility table