Next.js to TanStack Start: 5-Phase SEO Migration Playbook

Migrated 2 Next.js sites to TanStack Start in 14 days. Zero traffic loss. 5-phase SEO playbook for redirects, meta, sitemap. Ship ships it ready.

Huifer
Huifer
May 29, 20269 min read
Also available in:Deutsch

Written by Huifer, solo developer and maintainer of TanStack Ship. I migrated two production sites from Next.js to TanStack Start in 2025–2026 — a B2B SaaS on Next.js 14 with 320 indexed URLs and a B2C marketplace on Next.js 15 with 1,840 indexed URLs. Both finished in 14 working days with zero permanent traffic loss, recovered baseline organic traffic within 21 days, and shipped on the Cloudflare Workers stack that TanStack Ship ships by default. This is the SEO migration playbook I wish I had before the first migration, covering 308 redirects, canonical and meta tags, sitemap chunks, robots, and the Search Console handshake. Every claim links to a first-party source.

Verified sources: TanStack Start documentation · TanStack Router docs · Cloudflare Workers · Google Search Central — 301/308 redirects · Google Search Central — sitemaps · Google Search Central — robots.txt · Google URL Inspection API · Cloudflare R2 · Hono on Workers · RFC 7538 — 308 Permanent Redirect · Last updated: 2026-05-29 · Changelog


TL;DR: Migrating Next.js to TanStack Start can preserve SEO when run as a 5-phase playbook: (1) inventory URLs from Search Console, (2) ship 308 redirects at the edge before any application code, (3) map Next.js metadata to TanStack Router's meta() API in __root.tsx and each route, (4) split the sitemap into chunks respecting Google's 50 MB / 50,000 URL cap, (5) close the loop in Search Console with the URL Inspection API. Two production sites (320 URLs and 1,840 URLs) finished in 14 working days with zero permanent traffic loss and full traffic recovery in 21 days. TanStack Ship ships this stack ready.


Next.js to TanStack Start: The 5-Phase SEO Migration Playbook

The honest question for a Next.js team considering TanStack Start in 2026 is not "will it run?" — that is settled. The question is "will Search Console, sitemaps, redirects, and the metadata that earned our rankings survive the move without a 6-month traffic recovery arc?" This playbook answers that question with two production migrations I shipped end to end, and the exact sequence that let both finish in 14 working days.

Phase 1: URL Inventory and Baseline

You cannot migrate SEO without knowing what you have. The first phase is a hard inventory, mapped one-to-one to the destination URL on TanStack Start.

Pull every indexed URL from Search Console

Start with the canonical inventory. Search Console's Performance report exposes every URL Google has indexed and the queries that surfaced it. Export the full URL list — not just the top 1,000 — so you have a complete map of the source-of-truth URLs that earn your traffic. Group them by route type: marketing pages, documentation, blog posts, dashboards, API-only routes that should not be indexed. For each URL, write down the current canonical and the new canonical on TanStack Start; the two will not match unless your route structure is trivially one-to-one.

Capture per-URL traffic and query mix

For each top 50 URL by clicks, capture the click count, average position, and the top three driving queries. These are your "must not lose" URLs — every redirect, every canonical change, every sitemap rewrite pivots around preserving them.

For the B2B migration, the top 50 URLs accounted for 76% of organic clicks. For the B2C marketplace, the top 200 URLs accounted for 81%. The Pareto is sharp.

Decide what dies on the new stack

Not every URL survives. If a route only exists because Next.js's file-based routing forced a path, that path probably should not be on TanStack Start. Mark "no redirect needed" entries where the old URL should return 410 Gone. Search Console treats 410 as a deliberate removal and de-indexes the URL faster than 404. Both migrations shed around 80 dead URLs each this way, with zero traffic loss.

Phase 2: 308 Redirects First, Before Application Code

Redirects are the single highest-leverage SEO surface in the migration. Get them right first; everything else can be patched.

Why 308 over 301

Per Google Search Central on 301 redirects, both 301 and 308 preserve PageRank in practice. The difference is the request method: 301 allows the client to switch POST to GET in the next request, which is wrong for any form postback or API endpoint. 308 preserves the request method, which is what RFC 7538 standardizes. Use 308 for any URL where a method change could matter; reserve 301 for legacy HTTP/1.0 clients.

Implement redirects at the edge, not in app code

In Next.js, redirects often live in next.config.js — redirects() rewrites — or in middleware.ts. Both run inside the same Node process as the app. On TanStack Start, the right pattern is to push redirects into the Cloudflare Workers layer using a small router like Hono or a static redirect map. Edge redirects run before any application code, before any framework warmup, and add sub-millisecond overhead to the response.

A static redirect map in worker.ts looks like this:

ts
// workers/redirect.ts (compiled into the Worker's fetch handler)
import { Hono } from 'hono';

const redirects: Record<string, string> = {
  '/pricing-2024': '/pricing',
  '/docs/v1/quickstart': '/docs/quickstart',
  '/blog/2025-08-launch-announcement': '/blog/launch-announcement',
  // ... all other redirects from the URL inventory
};

const app = new Hono();

app.get('*', (c) => {
  const url = new URL(c.req.url);
  const target = redirects[url.pathname];
  if (target) {
    return c.redirect(target, 308);
  }
  return c.env.ASSETS.fetch(c.req.raw);
});

export default app;

Compile this with Wrangler and deploy ahead of the application code. The redirects are live and recognized by Googlebot within hours, not days.

Validate with curl and HTTP/2

Once deployed, validate every redirect with curl -I:

bash
curl -I https://yoursite.com/pricing-2024
# HTTP/2 308
# location: /pricing

Run the same check for the top 50 URLs by traffic. Anything that returns 200 instead of 308 is a bug to fix before phase 3. Both migrations caught a few typo'd entries on the first pass — most from Next.js dynamic catchall routes that had since changed.

Phase 3: Meta Tags via TanStack Router Head

TanStack Router has no first-party <Head> component like Next.js's metadata export. The community-blessed pattern is to set meta tags inside the route component itself through the meta() function. This phase maps Next.js metadata to TanStack Router.

Page-level meta

In Next.js, you write:

tsx
// next: app/blog/[slug]/page.tsx
export const metadata = {
  title: 'Post Title — Site',
  description: '...',
  openGraph: { title: '...', images: ['/og.png'] },
};

export default function Page({ params }) { /* ... */ }

In TanStack Router, the equivalent lives inside the route:

tsx
// tanstack: src/routes/blog/$slug.tsx
import { createFileRoute } from '@tanstack/react-router';

export const Route = createFileRoute('/blog/$slug')({
  loader: ({ params }) => fetchPost(params.slug),
  component: PostPage,
  meta: ({ loaderData }) => [
    { title: `${loaderData.title} — Site` },
    { name: 'description', content: loaderData.excerpt },
    { property: 'og:title', content: loaderData.title },
    { property: 'og:description', content: loaderData.excerpt },
    { property: 'og:image', content: loaderData.ogImage },
    { name: 'twitter:card', content: 'summary_large_image' },
  ],
});

function PostPage() {
  const { title, body } = Route.useLoaderData();
  return <article><h1>{title}</h1>{body}</article>;
}

The meta callback re-runs on every navigation, so metadata stays current as the user moves through the app.

Document-level meta and verification tokens

Site-wide meta — the brand title template, the verification tokens for Google and Bing, the canonical default — lives in the root route's meta. Treat it like _app.tsx:

tsx
// src/routes/__root.tsx
export const Route = createRootRoute({
  meta: () => [
    { title: 'Site Name — Tagline' },
    { name: 'viewport', content: 'width=device-width, initial-scale=1' },
    { name: 'google-site-verification', content: 'abc123' },
  ],
  component: RootLayout,
});

For canonical URLs, set them in the route's meta as <link rel="canonical">:

tsx
meta: ({ loaderData }) => [
  { tagName: 'link', rel: 'canonical', href: `https://yoursite.com/blog/${loaderData.slug}` },
],

tagName is what TanStack Router uses to differentiate <link> from <meta> in the document head. The __root.tsx default canonical can be overridden at the child route level when needed.

Phase 4: Sitemap Chunking and Robots

Sitemaps fail in three ways during migrations: the new framework emits too many URLs in one file, the sitemap fetches as 200 but contains zero <loc>, or the sitemap cross-references URLs that 404. Phase 4 fixes all three.

Respect Google's 50 MB / 50,000 URL cap

Per Google Search Central on sitemaps, a single sitemap file is capped at 50 MB uncompressed and 50,000 URLs. Past either limit, Google ignores the file silently. For the B2C marketplace migration, a single Next.js sitemap.xml held 4,200 URLs — fine. But the new TanStack Start site had a /blog index plus per-post URLs plus per-product URLs that pushed past 50,000 if rendered as a single file.

Generate per-section sitemaps and a sitemap index

The solution is a sitemap index that fans out to per-section files. Build them at deploy time so they always reflect the current route tree:

ts
// scripts/generate-sitemaps.ts
import { writeFile, mkdir } from 'node:fs/promises';
import { globby } from 'globby';

async function main() {
  const blogUrls = await globby(['src/routes/blog/**/+page.tsx']);
  const blogXml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${blogUrls.map((u) => `  <url><loc>https://yoursite.com${u.replace(/src\/routes/, '').replace(/\/\+page\.tsx$/, '/').replace(/\.tsx$/, '')}</loc></url>`).join('\n')}
</urlset>`;

  await mkdir('public/sitemaps', { recursive: true });
  await writeFile('public/sitemaps/sitemap-blog.xml', blogXml);

  // ... repeat for docs, products, etc.

  const index = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap><loc>https://yoursite.com/sitemaps/sitemap-blog.xml</loc></sitemap>
  <sitemap><loc>https://yoursite.com/sitemaps/sitemap-docs.xml</loc></sitemap>
</sitemapindex>`;
  await writeFile('public/sitemaps/sitemap-index.xml', index);
}

main();

Upload the directory to Cloudflare R2 (cheap, permanent) and serve it via a Worker route. The generated sitemap regenerates on every deploy and never drifts from the routes that exist.

Verify robots and the sitemap pointer

A clean robots.txt is the second part. From Google Search Central on robots.txt, make sure the sitemap pointer matches the canonical sitemap URL:

ts
// src/routes/robots[.]txt.ts
import { createFileRoute } from '@tanstack/react-router';

export const Route = createFileRoute('/robots[.]txt')({
  loader: () => ({
    'User-agent': '*',
    'Disallow': '/api/',
    'Sitemap': 'https://yoursite.com/sitemaps/sitemap-index.xml',
  }),
  component: () => null,
});

Validate with curl https://yoursite.com/robots.txt after deploy. The Disallow: /api/ keeps crawlers out of internal endpoints that don't render HTML — verified with the URL Inspection API in both migrations.

Phase 5: Search Console Handshake

The migration is not done when the new stack is live. Search Console is the closing loop that confirms Google sees the same URLs as your edge worker does.

Submit the new sitemap immediately

Submit the new sitemap URL in Search Console as soon as it returns 200 with valid XML. Use the URL Inspection API to request indexing for the top 50 URLs by traffic; the rest Google crawls on its own cadence.

Monitor the redirect-cache window

Google caches redirects for up to 180 days. Within the first 60 days after a redirect ships, old URLs that fire a request will return the cached destination — not the live one — and if the chain has changed in the meantime, Search Console reports a redirect-loop error. The migration playbook keeps the 308 map stable for at least 60 days; do not reshuffle redirects during the recovery window.

Watch the coverage report weekly for 21 days

For 21 days after the cutover, check the coverage report for soft 404s, redirect errors, and excluded URLs. Both migrations saw a 5–10% temporary dip in daily clicks between day 4 and day 14 as Google re-crawled the new domain mapping; the curve returned to baseline by day 21. Plan content cadence so the dip does not look like a regression.

Where TanStack Ship Fits in the Playbook

After two Next.js → TanStack Start migrations, the recurring setup I kept rebuilding was the redirects-at-edge, sitemap-chunking, and TanStack Router head-binding layer. TanStack Ship ships that stack ready out of the box:

  • A worker.ts template with Hono redirects wired to a redirects.json map
  • A generate-sitemaps.ts script that respects Google's 50 MB / 50,000 URL cap and emits a sitemap index
  • A __root.tsx and per-route meta() scaffold for canonical and Open Graph tags
  • A routes/robots[.]txt.ts handler that points Search Console at the sitemap index

The five phases above map 1:1 to the directory structure in TanStack Ship. The deployment target is Cloudflare Workers; the database layer is D1 with Drizzle ORM; the search layer is also wired in. If you are weighing the migration and would rather skip the 14-day setup, TanStack Ship is the faster path. For a closer look at the database side of the same stack, see the D1 production guide and the D1 vs Postgres comparison.

FAQ

Should I use 301 or 308 redirects during a Next.js to TanStack Start migration?

Use 308. Both preserve PageRank, but 308 keeps the HTTP method intact; 301 silently rewrites POST to GET. For any non-GET semantics, 308 is the correct choice per RFC 7538.

How long does Google take to honor new redirects?

Google caches redirects for up to 180 days per Google Search Central on 301 redirects. The crawl bot picks up new 308s within hours for high-traffic URLs, but the long tail may carry the cached destination for weeks. Plan for a 21-day traffic recovery window in your metrics.

Does TanStack Router have built-in Open Graph support?

Not as a first-party helper. The community-blessed pattern is to flatten OG tags inside meta() on each route, with site-wide defaults in __root.tsx. The TanStack Router docs cover the meta API in detail.

What about canonical URLs that change during the migration?

Set <link rel="canonical"> in the route's meta() array. Google respects canonical over sitemap for the same URL; if the canonical differs from the requested URL, Google indexes the canonical target.

Can I run Next.js and TanStack Start side-by-side during the migration?

Yes, via path-prefix routing on the same domain. I did this for the B2C migration: /legacy/* stayed on Next.js on Render, / and /blog cut to TanStack Start on Cloudflare. The redirect map handled the path-prefix shift; Search Console indexed both as one site.

How do I handle URLs that should not exist on the new stack?

Return 410 Gone, not 404. Search Console treats 410 as an explicit removal and drops the URL faster. Both migrations shed ~80 dead URLs this way without losing traffic to those URLs.