/ blog/migrating-tanstack-start-to-nextjs-app-router
blog / migrating-tanstack-start-to-nextjs-app-router / overview.md

Migrating from TanStack Start to Next.js App Router: An Architecture Post-Mortem

A deep dive into why we moved our entire CMS away from Vite SSR and TanStack Router, the performance implications of Server Components, and the hydration traps we had to fix.

The Motivation for the Migration

For the last year, this platform ran on a highly customized Vite SSR setup paired with TanStack Router. It was fast, incredibly type-safe, and gave us total control over the build pipeline. So why rewrite the entire core to use Next.js App Router?

The answer boils down to three architectural ceilings we hit:

  1. The JavaScript Bundle Tax: No matter how aggressively we code-split, heavy libraries like shiki (for syntax highlighting) and react-markdown were being shipped to the client to support client-side navigation.
  2. SEO and Hydration Cliffs: Rendering heavily interactive blog posts with embedded widgets led to hydration mismatches unless we perfectly mirrored state between the Vite SSR pass and the client.
  3. Data Fetching Waterfalls: Fetching related posts, checking analytics, and resolving user sessions created nested useQuery waterfalls that pushed our First Contentful Paint (FCP) higher than acceptable.

Next.js App Router, specifically React Server Components (RSC), offered a systemic solution to all three.

The Mental Shift: Thinking in Boundaries

Migrating wasn't just a syntax change; it was a fundamental shift in how we think about component boundaries. In TanStack Router, everything is ultimately a client component that happens to run on the server first. In App Router, the default is the Server.

The Problem: Monolithic Client Components

In our old setup, BlogPost.tsx looked like this:

// Old TanStack/Vite approach
export function BlogPost({ post }) {
  const { data: related } = useQuery({ ... });
  const [activeHeading, setActiveHeading] = useState("");
  
  return (
    <article>
      <MarkdownRender content={post.content} />
      <TableOfContents onHeadingVisible={setActiveHeading} />
      <RelatedPosts posts={related} />
    </article>
  );
}

Because of the useState for the Table of Contents, this entire tree—including the heavy Markdown parser—had to be shipped as JS.

The Solution: Shattering the Monolith

In Next.js, we refactored this into strict Server/Client boundaries:

// Next.js App Router approach (page.tsx)
import { renderMarkdownServer } from "@/lib/markdown.server";
import { TableOfContentsClient } from "./toc.client";
import { RelatedPostsServer } from "./related.server";

export default async function BlogPostPage({ params }) {
  const post = await fetchPost(params.slug);
  const headings = extractHeadings(post.content);
  
  // This runs entirely on the server. Zero JS sent to client!
  const renderedContent = await renderMarkdownServer(post.content);

  return (
    <div className="layout">
      <main>
        {renderedContent}
        <RelatedPostsServer currentTags={post.tags} />
      </main>
      <aside>
        {/* Only the interactivity is shipped to the client */}
        <TableOfContentsClient headings={headings} />
      </aside>
    </div>
  );
}

Eliminating Shiki from the Client

One of our biggest wins was removing shiki from the client bundle. Shiki is a fantastic syntax highlighter, but it relies on an embedded WebAssembly engine (Onigasm/Oniguruma) and hundreds of kilobytes of grammar definitions.

By moving markdown parsing to a Server Component, we execute codeToHtml() at request time:

import { codeToHtml } from "shiki";

async function flushCode(codeStr: string, lang: string) {
  const highlightedHtml = await codeToHtml(codeStr, {
    lang: lang || "text",
    theme: "github-dark",
  });
  
  return (
    <CodeBlockClient lang={lang} code={codeStr}>
      <div dangerouslySetInnerHTML={{ __html: highlightedHtml }} />
    </CodeBlockClient>
  );
}

The CodeBlockClient is a tiny 2kb component that just handles the "Copy to Clipboard" button and state. The heavy lifting is permanently confined to the server.

Dealing with Cache Invalidation

Next.js caching is notoriously aggressive. Moving from Vite SSR (where every request runs the code) to Next.js meant dealing with the Next.js Data Cache and Full Route Cache.

We hit issues where our Admin CMS would publish a post, but the main site wouldn't reflect it.

The solution was implementing targeted On-Demand Revalidation using Server Actions:

"use server";
import { revalidateTag, revalidatePath } from "next/cache";

export async function publishPost(id: string) {
  await db.posts.update({ where: { id }, data: { published: true } });
  
  // Purge the specific post and the blog index
  revalidateTag(`post-${id}`);
  revalidatePath("/blog");
}

The Results

The migration took roughly 3 weeks of dedicated engineering time. The metrics post-deployment speak for themselves:

  • Client JS Bundle: Reduced by 74% (removed Shiki, remark, rehype).
  • Time to Interactive (TTI): Dropped from 1.2s to 300ms.
  • Lighthouse SEO Score: Stabilized at a perfect 100 due to reliable, non-hydrated static HTML for crawlers.

Moving to App Router wasn't a trivial rewrite, but it aligned our technical architecture with our requirement for a premium, heavily-optimized technical blog.

Tags

nextjsarchitecturereactperformance
0
0