israel.olaleye
All posts

May 14, 2026

Migrating a Large SPA to the Next.js App Router

nextjsreactarchitecture

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.