Next.js: Migrating from Pages Router to App Router with Strategy
How to gradually migrate existing Next.js applications to App Router without rewriting everything, using hybrid functionality and incremental transition.
Executive summary
How to gradually migrate existing Next.js applications to App Router without rewriting everything, using hybrid functionality and incremental transition.
Last updated: 3/17/2026
The transition dilemma
Next.js App Router (introduced in Next.js 13) offers powerful features: React Server Components, streaming, nested layouts, and data parallelism. But migrating an established Pages Router application is non-trivial. Code in _app.tsx, _document.tsx, getServerSideProps and getStaticProps doesn't work directly.
The common mistake is attempting "big bang migration" — migrate everything at once. This means months of development with high risk. A better approach is incremental migration: both routers coexist while you move functionality gradually.
Hybrid architecture: routers can coexist
Next.js 13+ allows Pages Router and App Router in the same project. This feature was specifically built for migrations.
my-app/
├── app/ # App Router (/app/* routes)
│ ├── layout.tsx
│ ├── page.tsx
│ └── dashboard/
│ └── layout.tsx
├── pages/ # Pages Router (/pages/* routes)
│ ├── _app.tsx
│ ├── _document.tsx
│ └── legacy/
│ └── page.tsx # /legacy serves here
└── public/Precedence rules:
- Routes in
app/take precedence overpages/ pages/routes only serve if no match exists inapp/- Both share the same
public/and configuration
Phase-by-phase migration strategy
Phase 1: Preparation and infrastructure (1-2 weeks)
typescript// 1. Add App Router keeping Pages Router
// Create app/layout.tsx without breaking pages/
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{/* Header/Footer can be shared */}
<Header />
{children}
<Footer />
</body>
</html>
);
}typescript// 2. Create app/page.tsx (new homepage)
// Test that App Router works without breaking app/pages/index.tsx
export default function HomePage() {
return (
<div>
<h1>Homepage (App Router)</h1>
</div>
);
}typescript// 3. Configure style sharing
// app/layout.tsx imports existing global styles
import '../styles/globals.css';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
</body>
</html>
);
}Phase 2: Public pages migration (2-4 weeks)
Public pages (homepage, about, contact) are good starting points:
typescript// Before: pages/index.tsx with getStaticProps
export default function Home({ posts }: { posts: Post[] }) {
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}
export async function getStaticProps() {
const posts = await fetchPosts();
return { props: { posts } };
}typescript// After: app/page.tsx with Server Component
export default async function Home() {
const posts = await fetchPosts();
return (
<div>
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
);
}Benefit: server components eliminate need for getStaticProps and simplify code.
Phase 3: Shared components migration (3-6 weeks)
Components that work in both routers need special attention:
typescript// Component compatible with both routers
// Use Client Component only when needed
'use client'; // Only if it needs hooks/interactivity
export function InteractiveButton({ onClick }: { onClick: () => void }) {
return (
<button onClick={onClick}>
Click here
</button>
);
}typescript// Server Component (default in App Router)
// Can fetch data directly
export async function PostList() {
const posts = await fetchPosts();
return (
<div>
{posts.map(post => <PostCard key={post.id} post={post} />)}
</div>
);
}**Heuristic for use client:**
- Components with
useState,useEffect, event handlers →'use client' - Presentational components without hooks → Server Component (default)
- Components with client-only libraries →
'use client'
Phase 4: Business routes migration (4-8 weeks)
Business routes (dashboard, checkout, profile) are more complex:
typescript// Before: pages/dashboard/[id].tsx
export default function DashboardPage({ params }: { params: { id: string } }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/dashboard/${params.id}`)
.then(res => res.json())
.then(setData)
.finally(() => setLoading(false));
}, [params.id]);
if (loading) return <Spinner />;
return <DashboardView data={data} />;
}
export async function getServerSideProps({ params }: GetServerSidePropsContext) {
// Authentication happens here
const session = await getSession();
if (!session) {
return { redirect: { destination: '/login', permanent: false } };
}
return { props: {} };
}typescript// After: app/dashboard/[id]/page.tsx
export default async function DashboardPage({
params
}: {
params: { id: string };
}) {
// Fetch data directly on server
const data = await fetchDashboardData(params.id);
return <DashboardView data={data} />;
}
// app/dashboard/[id]/layout.tsx with middleware
import { auth } from '@/app/lib/auth';
export default async function DashboardLayout({
children,
params
}: {
children: React.ReactNode;
params: { id: string };
}) {
// Authentication in layout
const session = await auth();
if (!session) {
redirect('/login');
}
return (
<DashboardShell>
{children}
</DashboardShell>
);
}Authentication patterns:
- Pages Router:
getServerSideProps→ optional middleware - App Router: middleware or layout → Server Component auth
Phase 5: API routes refactoring (2-4 weeks)
typescript// Before: pages/api/users.ts
import { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
const users = await getUsers();
res.json(users);
}typescript// After: app/api/users/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
const users = await getUsers();
return NextResponse.json(users);
}Benefit: App Router API Routes use Web Standard Request/Response, more standardized.
Specific transition patterns
Sharing state between routers
typescript// shared/store.ts works in both routers
import { createStore } from 'zustand';
type Store = {
user: User | null;
setUser: (user: User) => void;
};
export const useStore = createStore<Store>((set) => ({
user: null,
setUser: (user) => set({ user }),
}));typescript// pages/_app.tsx (Pages Router)
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}typescript// app/layout.tsx (App Router)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<StoreProvider>
{children}
</StoreProvider>
</body>
</html>
);
}Migrating layouts
typescript// Before: pages/_app.tsx with global layout
export default function MyApp({ Component, pageProps }: AppProps) {
return (
<>
<Header />
<main>
<Component {...pageProps} />
</main>
<Footer />
</>
);
}typescript// After: app/layout.tsx (root layout)
import Header from '@/components/Header';
import Footer from '@/components/Footer';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Header />
<main>
{children}
</main>
<Footer />
</body>
</html>
);
}
// app/dashboard/layout.tsx (nested layout)
import DashboardNav from '@/components/DashboardNav';
export default function DashboardLayout({
children
}: {
children: React.ReactNode;
}) {
return (
<>
<DashboardNav />
<div className="dashboard-content">
{children}
</div>
</>
);
}Benefit: nested layouts allow shared UI per route group without repetition.
Migration anti-patterns
Anti-pattern 1: 'use client' on everything
typescript// BAD: Every file has 'use client'
'use client';
export function StaticContent() {
return <div>Static content</div>;
}Solution:
typescript// GOOD: Remove 'use client', use Server Component
export function StaticContent() {
return <div>Static content</div>;
}Anti-pattern 2: Copy getServerSideProps logic
typescript// BAD: Tries to replicate getServerSideProps
export default async function Page() {
const { searchParams } = props;
const data = await fetchData(searchParams);
return <View data={data} />;
}
// Trying to recreate getInitialProps
export async function generateMetadata() {
// This is not getInitialProps!
}Solution:
typescript// GOOD: Use Server Component with direct fetch
export default async function Page({
searchParams
}: {
searchParams: { [key: string]: string | string[] | undefined };
}) {
const data = await fetchData(searchParams);
return <View data={data} />;
}Anti-pattern 3: Ignore data streaming
typescript// BAD: Fetches everything before rendering
export default async function Page() {
const posts = await fetchPosts();
const comments = await fetchComments();
const analytics = await fetchAnalytics();
return (
<View
posts={posts}
comments={comments}
analytics={analytics}
/>
);
}Solution:
typescript// GOOD: Use fetch parallelism
import PostList from '@/components/PostList';
import CommentList from '@/components/CommentList';
import AnalyticsView from '@/components/AnalyticsView';
export default async function Page() {
const [posts, comments, analytics] = await Promise.all([
fetchPosts(),
fetchComments(),
fetchAnalytics()
]);
return (
<div>
<Suspense fallback={<PostListSkeleton />}>
<PostList posts={posts} />
</Suspense>
<Suspense fallback={<CommentListSkeleton />}>
<CommentList comments={comments} />
</Suspense>
<AnalyticsView analytics={analytics} />
</div>
);
}Benefit: streaming allows progressive rendering while data loads.
Testing migration gradually
Rollout strategy
typescript// Feature flag to control migration
const USE_APP_ROUTER = process.env.NEXT_PUBLIC_USE_APP_ROUTER === 'true';
export default function HomePage() {
if (USE_APP_ROUTER) {
// Use App Router component
return <AppRouterHomePage />;
}
// Use original Pages Router component
return <PagesRouterHomePage />;
}Bug monitoring
typescript// A/B test between routers
import { useFeatureFlag } from '@/hooks/useFeatureFlag';
export default function HomePage() {
const useAppRouter = useFeatureFlag('app-router-homepage');
if (useAppRouter) {
return <AppRouterHomePage />;
}
return <PagesRouterHomePage />;
}typescript// Track performance by router
export function withRouterTracking<P extends object>(
Component: React.ComponentType<P>,
routerType: 'app' | 'pages'
) {
return function TrackedComponent(props: P) {
useEffect(() => {
metrics.track('page_load', {
router_type: routerType,
page: typeof window !== 'undefined' ? window.location.pathname : 'unknown'
});
}, []);
return <Component {...props} />;
};
}Completion checklist
Per migrated feature, verify:
- [ ] Visual and functional rendering identical
- [ ] SEO (metadata, Open Graph) preserved
- [ ] Authentication working correctly
- [ ] Performance not degraded (Core Web Vitals)
- [ ] Error logs didn't increase
- [ ] Internal links work
- [ ] Forms submit correctly
Conclusion
Migrating from Pages Router to App Router doesn't have to be a traumatic big bang event. Next.js's hybrid architecture allows incremental transition: new features in App Router, legacy code in Pages Router, both coexisting while you migrate feature by feature.
The right strategy depends on application complexity and tolerance for change risk. Incremental approach reduces technical risk: you can test each migration in isolation, rollback if necessary, and learn from problems before migrating critical code.
More important than migration speed is transition quality. Public pages first, shared components later, business routes last. Each phase adds complexity — make sure the previous is stable before advancing.
The final result is worth the effort: Server Components, nested layouts, data streaming, and fetch parallelism make the application faster and more efficient. Migration is investment in performance and developer experience that pays off over time.
Need to migrate your Next.js application to App Router with technical predictability? Talk to Imperialis React and Next.js experts to design and execute a safe incremental migration strategy.