Everything I learned migrating a production app from the Pages Router to the App Router — the good, the bad, and the genuinely surprising.
The App Router landed in Next.js 13 and promised a lot: server components, streaming, nested layouts, and a fundamentally different mental model for building React apps. After migrating a production codebase to it, I have thoughts.
The Pages Router works. It's battle-tested and most teams understand it well. But after spending time with the App Router, the performance difference is real — especially for data-heavy pages that previously required waterfall fetches.
The old pattern looked something like this:
// pages/dashboard.tsx — everything fetches on the client
export async function getServerSideProps() {
const [user, stats, feed] = await Promise.all([
fetchUser(),
fetchStats(),
fetchFeed(),
]);
return { props: { user, stats, feed } };
}That works, but it blocks the entire page render on three network calls. With the App Router and React Suspense, you can stream each section independently.
The mental model shift isn't "new file structure" — it's components that run on the server by default.
This means no more API routes just to proxy data to the client. Your component can talk directly to your database:
// app/dashboard/page.tsx
async function DashboardPage() {
const user = await db.user.findFirst(); // runs on server, zero client JS
return <UserProfile user={user} />;
}The component above ships zero JavaScript to the browser. The HTML arrives fully rendered. For read-heavy UIs, this is transformative.
"use client" boundaryThe hardest part of the migration was understanding the client/server boundary. The rule is simple in theory: add "use client" to any component that uses browser APIs, event handlers, or hooks. In practice, this means restructuring component trees you've written habitually.
The biggest mistake I made early on: marking entire pages as client components because one tiny interactive element needed useState. The fix is to extract the interactive parts into small client islands:
// Before: entire page is client
"use client";
export default function Page({ data }) {
const [open, setOpen] = useState(false);
return (
<div>
<HeavyDataTable data={data} /> {/* doesn't need to be client! */}
<button onClick={() => setOpen(true)}>Open</button>
</div>
);
}
// After: only the button is client
// app/page.tsx (server)
export default async function Page() {
const data = await fetchData();
return (
<div>
<HeavyDataTable data={data} />
<OpenButton /> {/* small client island */}
</div>
);
}Nested layouts solve a problem I'd been hacking around for years. Previously, keeping a sidebar in sync across route transitions required either global state or re-fetching. Now:
app/
layout.tsx ← root layout (nav, footer)
dashboard/
layout.tsx ← dashboard sidebar — mounts once, never re-renders
page.tsx
settings/
page.tsxThe dashboard sidebar fetches its own data and mounts exactly once. Navigating between /dashboard and /dashboard/settings doesn't touch it.
Error handling is more verbose. The error.tsx boundary file works, but wiring up meaningful error states with recovery options takes more code than I'd like.
Testing is awkward. Server components can't be tested with the usual React Testing Library setup. You end up writing more integration tests and fewer unit tests, which isn't necessarily bad — but it's a change.
The learning curve is real. Junior engineers on my team struggled with the server/client boundary more than I expected. The mental model requires a solid understanding of React's rendering pipeline.
The App Router is the right direction. Streaming, server components, and nested layouts solve real problems. The migration cost is non-trivial, but for any app where performance matters, it's worth it.
Start new projects on the App Router. Migrate existing ones incrementally — the two routers coexist without issue.