May 14, 2026
Migrating a Large SPA to the Next.js App Router
Moving a mature single-page app to the Next.js App Router is less about the router and more about rethinking where your code runs. Here are the lessons that mattered most on a real migration.
Start with the leaves, not the shell
The instinct is to migrate the app shell first. Resist it. Start with leaf routes — settings pages, detail views — where the blast radius is small and the wins are immediate.
Server Components change your data story
Most of our client-side data fetching simply disappeared. A component that used to orchestrate loading states now just awaits its data:
export default async function ProjectPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const project = await getProject(id);
return <ProjectDetail project={project} />;
}No useEffect, no loading spinners for the initial render, no client-side
waterfalls. The loading.tsx convention handles the pending state at the
route level.
Keep client components at the edges
The pattern that served us best: server components own layout and data,
client components own interaction. Push "use client" as far down the tree
as you can.
// Server component — fetches and lays out
export async function CommentList({ postId }: { postId: string }) {
const comments = await getComments(postId);
return (
<section>
{comments.map((c) => (
<Comment key={c.id} comment={c} />
))}
<CommentForm postId={postId} /> {/* client island */}
</section>
);
}The payoff
After the migration our p75 LCP dropped from 4.2s to 1.4s, and the client bundle shrank by roughly 60%. The mental model shift is real, but the performance ceiling is dramatically higher.