← Blog
Next.js

Next.js App Router in Production: Lessons from Real Client Projects

By Muzamal Ali7 min readNext.js · App Router · Server Components
Next.js App Router in Production: Lessons from Real Client Projects — Next.js article by Muzamal Ali

I have moved three production codebases from the Pages Router to the App Router, and the pattern is consistent: the folder rename takes an afternoon, the mental model takes a fortnight, and the caching behaviour takes a genuine incident before the team internalises it. This is what I would tell a team starting that migration next Monday — the order to move routes in, the gotchas that cost me real debugging days, and the one thing that broke badly enough to warrant a rollback.

The mental model shift

The App Router is not the Pages Router with new folder names. It inverts the default: everything is a Server Component until you opt into the client. Teams that struggle with it are usually fighting that default — sprinkling "use client" at the top of every file and wondering why the bundle did not shrink. The win comes from pushing client boundaries to the leaves: interactive islands inside server-rendered shells.

Layouts that actually persist

Nested layouts persist across navigation — state survives, fetches do not re-run. In practice this means navbars, sidebars, and providers belong in layouts, and per-page work belongs in pages. On one client dashboard, moving a heavy user-context provider from page level into the segment layout removed a visible flash on every navigation.

Where data fetching wants to live

Fetching in Server Components with async/await removes the loading-spinner waterfall that plagued client-fetched dashboards. My rules of thumb on production projects:

  • Fetch in the layout or page on the server, pass data down as props
  • Use loading.tsx and streaming for slow segments instead of blocking the whole route
  • Keep client-side fetching (RTK Query, SWR) for data that changes after interaction — filters, live updates, mutations

What order should you migrate routes in?

Migrate in order of decreasing payoff and increasing risk. The sequence that has worked on every project I have run:

  • Marketing and content pages first. Static or near-static routes with no auth, no personalisation, and no client state. Server Components pay off immediately in bundle size, and if something breaks the blast radius is a landing page rather than a checkout.
  • Read-only authenticated pages second. Lists, detail views, reports. These teach the team server-side data fetching and the auth pattern without mutation complexity.
  • Shared layout and providers third. Once several routes have moved, the persistent-layout wins become obvious and you have enough real usage to place providers correctly rather than guessing.
  • Mutation-heavy dashboards last. Forms, optimistic updates, anything with a client-side cache like RTK Query. These carry the most behavioural risk and benefit least from server rendering.

Two practical notes. The routers coexist in the same project — app/ takes precedence for a path that exists in both, so you can move one route, ship it, and watch production before moving the next. And migrate whole routes rather than half-converting a page; a route that is partly server and partly a client tree with old data assumptions is harder to reason about than either pure form.

On the last migration I ran, marketing pages shipped in week one and the authenticated dashboard took four weeks. Both estimates were right precisely because we did not attempt them in the same sprint.

Caching is now an architectural decision

Next.js caches aggressively, and the defaults have changed across versions — which is exactly why caching needs to be explicit in code review. Tag your fetches, decide revalidation windows deliberately, and document them. The bugs that erode stakeholder trust fastest are stale-data bugs, because they look like the app is lying.

Which caching gotchas actually bite?

Four, in the order I have hit them.

Fetch results are cached, and the default has moved between versions. Never rely on the version default — state your intent at every call site:

// Per-request freshness — dashboards, anything user-specific
const res = await fetch(url, { cache: "no-store" });

// Cached, refreshed at most every 60s — pricing, marketing content
const res = await fetch(url, { next: { revalidate: 60 } });

// Cached until something explicitly invalidates the tag
const res = await fetch(url, { next: { tags: ["projects"] } });

Writing the option even when it matches the current default is not noise. It survives a Next.js upgrade, and it tells a reviewer what the author actually intended.

Tag invalidation is what makes caching usable. Time-based revalidation is a guess; tag-based is a fact. After a mutation, invalidate precisely:

"use server";
import { revalidateTag, revalidatePath } from "next/cache";

export async function updateProject(id: string, data: ProjectInput) {
  await db.project.update({ where: { id }, data });
  revalidateTag("projects");           // every fetch tagged "projects"
  revalidatePath(`/projects/${id}`);   // and this specific route
}

Route segments cache independently of your fetches. A statically rendered page stays static even if the data underneath changed; export const dynamic = "force-dynamic" or a segment-level revalidate export is the lever. Teams routinely fix the fetch, see nothing change, and conclude caching is broken — the segment was the layer holding the old render.

The client router cache surprises everyone. Next.js caches the payload for visited routes for a short window, so navigating back to a page you just mutated can briefly show the previous data. router.refresh() after a mutation is the fix. This is the gotcha that generates the most "it works when I reload" bug reports, because reloading bypasses exactly the cache involved.

When should you use a server action versus a route handler?

My rule is about who calls it, not what it does. If the caller is your own React tree, use a server action — it removes an entire API layer, keeps types end to end, and integrates with revalidateTag. If the caller is anything else, use a route handler, because that thing needs a stable URL contract that will not change when you refactor a component.

Server action    — form submit, row delete, settings save, optimistic UI
Route handler    — Stripe webhook, mobile app, cron job, partner integration, OG image

Two cautions from review. Server actions are real POST endpoints regardless of how they look in the editor, so every one of them needs authorisation and input validation inside the action itself; being imported by an authenticated component proves nothing about who invoked it. And an action that gets called by both a form and a background job usually wants to become a shared function with two thin callers, rather than a route handler that duplicates the logic.

Migration advice from real projects

Migrate route by route, not big bang. The App and Pages routers coexist fine. Start with marketing or content pages where Server Components pay off immediately, leave complex authenticated dashboards for last, and budget time for the caching behavior to be re-learned by the whole team. On a recent migration, the marketing pages shipped in week one; the dashboard took a month of careful, incremental moves — and that pacing was right.

What broke during a real migration?

The incident worth describing was a stale-pricing bug that reached customers for about six hours.

The pricing page had been migrated in the first week — a static marketing route, exactly the low-risk category I recommend starting with. It fetched plan data from the CMS with no cache option, which under the version we were on meant indefinite caching. In staging nobody noticed, because every deploy produced a fresh build. In production the page kept serving build-time prices after marketing published an update, and the first report came from a customer quoting a price the sales team no longer offered.

The immediate fix was a tag on the fetch plus a revalidateTag call in the CMS webhook, which took about twenty minutes. The useful part was the post-mortem. Three things changed:

  • Every fetch in a Server Component must state a cache option explicitly. We added a lint rule; unstated intent is not reviewable.
  • Content freshness became a test, not an assumption. A smoke check hits the pricing route after a CMS publish and fails if the payload has not changed within the revalidation window.
  • Caching moved into the PR template. One checkbox: "what is the freshness requirement for data on this route, and what enforces it?"

What I would do differently is smaller than any of that. The route was migrated by someone who had read the docs but never watched a cached route go stale in production, and it was reviewed by someone in the same position. On subsequent migrations I have paired the first three routes with whoever has actually been burned before — the knowledge that matters here is experiential, and a fortnight of docs does not substitute for it.

The other thing that broke, less dramatically, was our client-side auth guard. Under the Pages Router a hook redirected unauthenticated users on mount. Under the App Router the server rendered the page first, so protected content briefly existed in the HTML payload before the client redirect ran. Not a data leak in our case — the API still rejected the requests — but a genuinely bad look, and a real leak in a codebase where the page renders data directly. Auth checks belong in the layout or middleware on the server; a client-side redirect is a UX affordance, not a security boundary.

Verdict

The App Router rewards teams that embrace server-first thinking and punishes teams that fight it. The performance results are real — see the optimizations that cut our load time by 60% — but they come from using the model as designed, not from the folder rename.

If you are planning a migration and would rather have someone who has done it three times sequence it with you, that is a well-bounded engagement — the shape of it is on my services page.

Muzamal Ali

Muzamal Ali — Senior Frontend Engineer & Team Lead

Senior Frontend Engineer with 5+ years building production React and Next.js applications. I've led teams of 3–9 developers across healthcare, aviation, AI, and SaaS platforms. Based in Pakistan, working async with European tech teams.

Working on something similar?

I help European tech teams ship better frontends.

Related Articles