When Next.js 13.4 marked the App Router as stable, we decided to migrate our internal admin dashboard. It was a good candidate: 40 pages, moderate complexity, no public traffic to worry about during the transition. The migration took three weeks, which was a week longer than we estimated.
This is a walkthrough of what the migration actually involved, structured around the decisions we made and the problems we hit.
The migration strategy
We didn't migrate everything at once. Next.js supports running Pages Router and App Router side by side, which meant we could migrate page by page without a big-bang cutover.
Our order:
- Layout and navigation (the shell that wraps every page)
- Static pages (about, docs, changelog)
- Data-fetching pages (dashboards, reports)
- Interactive pages (forms, wizards, settings)
This order worked because each phase had increasing complexity. By the time we reached interactive pages, we had already solved the common problems.
Layouts: the biggest conceptual shift
In Pages Router, your layout is a component you manually wrap around page content, usually in _app.tsx. In App Router, layouts are defined by file convention. A layout.tsx in a directory wraps all pages in that directory and its subdirectories.
app/
layout.tsx ← root layout (html, body)
dashboard/
layout.tsx ← dashboard layout (sidebar, nav)
page.tsx ← /dashboard
settings/
page.tsx ← /dashboard/settings
The key insight: layouts don't re-render when you navigate between sibling pages. In Pages Router, the layout component re-renders on every navigation because it's part of the page component tree. In App Router, the layout persists and only the page content swaps.
This was a genuine improvement. Our dashboard sidebar had a complex state (expanded sections, scroll position) that previously reset on every navigation. With the App Router layout, it persists automatically.
Server Components: the default that changes everything
In App Router, components are Server Components by default. They run on the server, return HTML, and never ship JavaScript to the client. This is the biggest architectural change and the source of most migration pain.
The rule is simple: if a component uses useState, useEffect, event handlers, or browser APIs, it needs the 'use client' directive. Everything else can stay as a Server Component.
In practice, the split was roughly 70/30 in our codebase: 70% of components could remain server-side, 30% needed the client directive.
The problems came from components that mixed concerns. A dashboard card that fetches data (server concern) and has a dropdown menu (client concern) needs to be split into two components:
// Server Component — fetches data
async function MetricCard({ metricId }: { metricId: string }) {
const data = await getMetric(metricId)
return (
<div>
<h3>{data.name}</h3>
<p>{data.value}</p>
<MetricCardMenu metricId={metricId} />
</div>
)
}
// Client Component — handles interaction
'use client'
function MetricCardMenu({ metricId }: { metricId: string }) {
const [open, setOpen] = useState(false)
return (
<button onClick={() => setOpen(!open)}>
{open && <DropdownMenu metricId={metricId} />}
</button>
)
}
This pattern appeared dozens of times in our migration. Every component that both fetched data and handled user interaction needed splitting.
Data fetching: from getServerSideProps to async components
In Pages Router, you fetch data in getServerSideProps and pass it as props to the page component. In App Router, page components are async and fetch data directly:
// Pages Router
export async function getServerSideProps() {
const users = await fetchUsers()
return { props: { users } }
}
export default function UsersPage({ users }: { users: User[] }) {
return <UserTable users={users} />
}
// App Router
export default async function UsersPage() {
const users = await fetchUsers()
return <UserTable users={users} />
}
The App Router version is simpler. No separate data-fetching function, no props serialisation boundary, no getServerSideProps naming convention. You just await your data and render it.
The subtlety is in caching. Next.js caches fetch requests by default in the App Router. If you call fetch(url) twice with the same URL, the second call returns the cached result. This is usually what you want, but it caught us off guard with endpoints that return different data based on the authenticated user.
We had to add { cache: 'no-store' } to several fetch calls and export const dynamic = 'force-dynamic' to pages that should never be cached.
Loading and error states
One genuinely nice improvement: loading and error UI is now file-convention-based.
app/
dashboard/
page.tsx
loading.tsx ← shown while page.tsx is loading
error.tsx ← shown if page.tsx throws
In Pages Router, you handle loading states manually in every page. In App Router, you define a loading component once and it applies to the entire route segment. The error boundary works the same way.
For our dashboard, this eliminated roughly 15 duplicate loading spinner implementations. Each page had its own loading state with slightly different styling. Now there's one loading.tsx per route group.
What took longer than expected
Client Component boundaries. Determining where to place 'use client' was the most time-consuming part. The rule is simple in theory, but in a real codebase, the dependency tree is deep. Marking one component as a client component means all its children are also client components. We had cases where a single useState deep in a component tree forced the entire branch to be client-rendered.
Third-party libraries. Several libraries we used didn't support Server Components. They used browser APIs at the module level, which causes an error when the module loads on the server. The fix is a dynamic import with { ssr: false }, but identifying which libraries needed this took trial and error.
Search params. In Pages Router, you read query parameters from useRouter().query. In App Router, page components receive searchParams as a prop, but client components must use the useSearchParams() hook. We had URL-dependent logic scattered across both server and client contexts, and harmonising this was tedious.
Was it worth it
Yes, with caveats. The layout persistence, the Server Component performance gains, and the simplified data fetching are real improvements. Our Lighthouse performance score went from 72 to 91 without any other changes, purely from shipping less JavaScript to the client.
The migration cost was about 120 engineer-hours across two developers over three weeks. For a production application with public users, I would budget more and run both routers in parallel for longer.
If you're starting a new Next.js project, use the App Router. If you're considering migrating an existing project, do it page by page and start with the simplest pages.
120 engineer-hours for 40 pages is a useful data point for estimating our own migration. We have about 80 pages and a lot more client-side interactivity, so I'm budgeting accordingly. The note about search params being tedious matches our experience so far.
The Lighthouse score improvement from 72 to 91 without other changes is a compelling number for getting buy-in from product managers. Thank you for including concrete metrics.