
Next.js 16 Migration: Full App Router Conversion Retrospective
Everything I hit while converting from Pages Router to App Router — migration strategy, Server Component gotchas, data fetching rewrites, and honest before/after performance numbers.

Everything I hit while converting from Pages Router to App Router — migration strategy, Server Component gotchas, data fetching rewrites, and honest before/after performance numbers.
I imported a Server Component inside a Client Component, and it broke everything. Here’s how to use the Composition Pattern (Donut Pattern) to fix it and correctly separate Context Providers.

My admin account was hijacked because of a single comment on the board. I dive deep into the 3 types of XSS (Stored, Reflected, DOM) and concrete defense strategies in React/Next.js environments, including HTML Escaping, CSP, and Cookie Security.

Do you know what the Circle (○) and Lambda (λ) symbols mean in Next.js build logs? Ensure you aren't accidentally making every page dynamic.

I built a service and nobody came. How I implemented technical SEO in Next.js to drive organic traffic without a marketing budget.

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.
One sentence: Pages Router is "request comes in, fetch data on server, render component." App Router is "component fetches data directly on the server."
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
// 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.
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.
The core of App Router is React Server Components (RSC). Getting comfortable with this took time.
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
// 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>
);
}
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()} />
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>;
}
"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>
);
}
// 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} />;
}
// 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} />;
}
// 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");
// 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.
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.
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.
Marking a component "use client" pulls its entire subtree into the client bundle. Put it on leaf nodes.
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.
// 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;
"use client" boundary thinking — took the whole team time to internalizeIf 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.