Next.js 16 Migration: Full App Router Conversion Retrospective
Prologue: "Why Now?"
Honestly, App Router shipped with Next.js 13. That was a while ago. Why migrate only now?
Pages Router was working. Deployments were stable. "Don't fix what isn't broken."
But as Next.js 15 and 16 landed, Pages Router warnings increased. App Router became the default in docs. The team wanted to try React Server Components. And decisively — the bundle was getting too big. Too much JavaScript going to the client unnecessarily.
So we did it. This is what we learned.
Pages Router vs App Router: What Actually Changed
One sentence: Pages Router is "request comes in, fetch data on server, render component." App Router is "component fetches data directly on the server."
Routing Structure
Pages Router:
pages/
index.tsx
blog/
index.tsx
[slug].tsx
api/
posts.ts
App Router:
app/
page.tsx
blog/
page.tsx
[slug]/
page.tsx
api/
posts/
route.ts
Data Fetching Paradigm Shift
// Pages Router — data comes in as props
export async function getServerSideProps({ params }) {
const post = await getPost(params.slug);
return { props: { post } };
}
export default function PostPage({ post }) {
return <article>{post.title}</article>;
}
// App Router — component fetches directly
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return <article>{post.title}</article>;
}
Conceptually cleaner. Data and rendering live in the same place.
Migration Strategy: Incremental vs Big Bang
Next.js supports both routers simultaneously. pages/ and app/ can coexist.
Incremental migration (recommended):
Step 1: Create app/ directory, set up layouts
Step 2: Migrate simple pages first
Step 3: Migrate complex pages (heavy client state, third parties)
Step 4: Remove pages/ completely
Our team's timeline:
Week 1: app/ scaffolding (layout, middleware, global styles)
Week 2-3: Static pages (About, Privacy, Terms)
Week 4-5: Dynamic pages (Blog, Projects)
Week 6: Complex pages + testing
Week 7: Remove pages/, cleanup
Big bang migrations are hard to roll back. Incremental is safer.
Server Components: The Biggest Mental Shift
The core of App Router is React Server Components (RSC). Getting comfortable with this took time.
The Basic Rule
Server Components (default):
- Run only on the server
- No JavaScript shipped to the browser
- Can directly access DB, APIs, filesystem
- Cannot use useState, useEffect, browser APIs
Client Components ("use client"):
- Also run in the browser (hydration)
- Can use useState, useEffect, event handlers
- Cannot do server-only operations
What Confused Me Most
// Server component — fine
export default async function PostList() {
const posts = await db.posts.findAll(); // Direct DB access
return (
<ul>
{posts.map(post => <PostCard key={post.id} post={post} />)}
</ul>
);
}
// Client component — needs "use client"
"use client";
export default function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? "❤️" : "🤍"}
</button>
);
}
Gotcha 1: Non-Serializable Props
Props passed from server to client components must be serializable.
// Won't work — functions can't be serialized
<ClientComponent onClick={() => console.log("clicked")} />
// Won't work — class instances can't be serialized
<ClientComponent date={new Date()} />
// Works — primitive values
<ClientComponent timestamp={Date.now()} />
Gotcha 2: Context API Limitations
Context only works in client components.
// Error
export default async function ServerComponent() {
const theme = useContext(ThemeContext); // Error!
}
// Solution: Wrap in a client component
"use client";
export default function ThemeWrapper({ children }) {
const theme = useContext(ThemeContext);
return <div className={theme}>{children}</div>;
}
Gotcha 3: Component Split Strategy
"When in doubt, make it a client component" is tempting, but that kills App Router's bundle size benefits.
The right approach: Keep everything server-side by default. Extract only the minimal interactive leaf nodes as client components.
// Bad — entire page as client component
"use client";
export default function PostPage({ params }) {
const [post, setPost] = useState(null);
useEffect(() => { fetchPost(params.slug).then(setPost); }, []);
return (
<article>
<h1>{post?.title}</h1>
<LikeButton postId={post?.id} /> {/* Only this needs client */}
</article>
);
}
// Good — only the interactive leaf is a client component
export default async function PostPage({ params }) {
const post = await getPost(params.slug); // Server fetch
return (
<article>
<h1>{post.title}</h1>
<LikeButton postId={post.id} /> {/* Client component */}
</article>
);
}
Data Fetching Changes
getServerSideProps → Server Component
// Before
export async function getServerSideProps({ params, req }) {
const session = await getSession(req);
if (!session) return { redirect: { destination: "/login" } };
const user = await getUser(session.userId);
return { props: { user } };
}
// After
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await getServerSession();
if (!session) redirect("/login");
// Parallel fetching
const [user, posts] = await Promise.all([
getUser(session.userId),
getUserPosts(session.userId),
]);
return <Dashboard user={user} posts={posts} />;
}
getStaticProps + getStaticPaths → generateStaticParams
// Before
export async function getStaticPaths() {
const posts = await getAllPosts();
return { paths: posts.map(p => ({ params: { slug: p.slug } })), fallback: "blocking" };
}
export async function getStaticProps({ params }) {
const post = await getPost(params.slug);
return { props: { post }, revalidate: 60 };
}
// After
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(p => ({ slug: p.slug }));
}
export const revalidate = 60; // ISR — module-level declaration
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
return <PostDetail post={post} />;
}
Fetch Cache Control
// Default: indefinite cache (static)
const data = await fetch("https://api.example.com/static");
// No cache: always fresh
const data = await fetch("https://api.example.com/live", { cache: "no-store" });
// Time-based revalidation (ISR)
const data = await fetch("https://api.example.com/data", { next: { revalidate: 60 } });
// Tag-based revalidation
const data = await fetch("https://api.example.com/posts", { next: { tags: ["posts"] } });
// Invalidate from Server Action
import { revalidateTag } from "next/cache";
revalidateTag("posts");
Metadata API
// Before — Head component
import Head from "next/head";
export default function PostPage({ post }) {
return (
<>
<Head>
<title>{post.title}</title>
<meta name="description" content={post.description} />
</Head>
<article>...</article>
</>
);
}
// After — typed generateMetadata
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
images: [{ url: post.coverImage, width: 1200, height: 630 }],
},
};
}
Much more explicit and type-safe. Gone are the duplicate <Head> bugs.
Server Actions
A bonus of App Router — call server functions directly from client without a dedicated API route.
// app/actions.ts
"use server";
export async function createPost(formData: FormData) {
const session = await getServerSession();
if (!session) return { error: "Unauthorized" };
const post = await db.posts.create({
title: formData.get("title") as string,
content: formData.get("content") as string,
authorId: session.userId,
});
revalidatePath("/blog");
return { success: true, postId: post.id };
}
// Client component
"use client";
export default function CreatePostForm() {
const [state, formAction] = useActionState(createPost, null);
return (
<form action={formAction}>
<input name="title" />
<textarea name="content" />
<button type="submit">Create</button>
</form>
);
}
No separate API route. The client-server boilerplate dropped noticeably.
Performance Results (Real Numbers)
Build output comparison:
Pages Router:
Route Size First Load JS
/blog 12.4 kB 98.7 kB
/blog/[slug] 8.2 kB 94.5 kB
App Router:
Route Size First Load JS
/blog 3.1 kB 78.4 kB (-20%)
/blog/[slug] 2.8 kB 74.2 kB (-21%)
Lighthouse (mobile):
Before After
Performance 72 89
FCP 2.4s 1.6s (-33%)
LCP 3.8s 2.1s (-45%)
TBT 380ms 120ms (-68%)
CLS 0.12 0.04
The bundle size drop directly drove the Lighthouse gains. Components that stayed on the server stopped going to the client.
Lessons Learned
1. Be Deliberate About "use client" Boundaries
Marking a component "use client" pulls its entire subtree into the client bundle. Put it on leaf nodes.
2. Use Suspense for Streaming
export default async function DashboardPage() {
return (
<div>
<UserProfile /> {/* Fast — renders immediately */}
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsWidget /> {/* Slow query — streams in later */}
</Suspense>
</div>
);
}
Pages load without waiting for slow data. Significant UX improvement.
3. Migration-Specific Gotchas
// Router import path changed
// Before
import { useRouter } from "next/router";
// After
import { useRouter } from "next/navigation";
// Also: redirect, usePathname, useSearchParams — all from next/navigation
// Params in client components
"use client";
import { useParams } from "next/navigation";
const params = useParams();
const slug = params.slug as string;
What Went Well, What Didn't
Improved:
- First Load JS down 20%
- LCP improved 45%
- Code is more intuitive (data + rendering in the same file)
- Typed Metadata API makes SEO explicit
- Server Actions cut API boilerplate
Was Hard:
"use client"boundary thinking — took the whole team time to internalize- Library compatibility — some libraries didn't fully support App Router yet
- Hydration mismatch debugging — when server and client render differently, it's tricky to track down
If you're starting a new project, App Router is the obvious choice. If you're migrating: go incremental, start with the team members who understand the new model, and don't forget — useRouter imports from next/navigation now.