All posts
migration
checklist
technical-seo

SEO Migration Checklist: Moving to a New Framework Without Losing Traffic

Step-by-step SEO checklist for framework migrations. Redirects, canonical URLs, metadata preservation, and automated before/after validation.

March 15, 20268 min

You pushed a Next.js rewrite on Friday. Monday morning, organic traffic dropped 40%. The culprit: 23 high-converting pages lost their meta descriptions in the component refactor, and 12 category pages 404'd because the routing logic changed from /category/[slug] to /shop/[slug].

Migrating frameworks—whether you are moving from an old PHP monolith to Next.js, or migrating from the Pages Router to the App Router—is the highest risk event in a site's lifecycle. Googlebot does not care about your new React Server Components. It cares that the canonical tags match, the 301 redirects resolve in under 300ms, and the JSON-LD schema remains intact.

When developers focus entirely on visual parity and ignore structural SEO parity, traffic dies. You need an automated, deterministic way to guarantee that your new framework outputs the exact same SEO footprint as the old one.

How do you snapshot your current SEO state?

You snapshot your current SEO state by crawling the production domain before writing any new code and exporting the entire metadata structure to a static JSON baseline.

Never rely on a spreadsheet provided by an external agency. Spreadsheets are immediately out of date and cannot be parsed by your CI pipeline. You need a machine-readable snapshot of every title, meta description, canonical URL, and Open Graph tag currently live in production.

Run the Indxel CLI against your existing production environment:

npx indxel crawl https://legacy-app.com --out current-state.json

This command spiders your existing site and generates a mapping of 15 critical data points per URL. The resulting current-state.json file becomes your source of truth. It records the exact string values of your metadata. If your legacy site has a title tag that is 58 characters long, your new site must output that exact 58-character string.

Commit this file to your new repository. You will use it to gate your deployments later.

Do not skip staging environments if your legacy production site blocks crawlers. If your current site sits behind a WAF that blocks automated requests, pass your authorization headers to the CLI using --header "Authorization: Bearer <token>".

How should you map old URLs to new URLs?

You map old URLs to new URLs by creating a 1:1 routing table that explicitly routes legacy paths to their exact new equivalents, avoiding blanket redirects to the homepage.

Framework migrations almost always introduce routing changes. A legacy app might use query parameters (/product.php?id=123), while your new Next.js app uses path variables (/products/123).

Failing to map these correctly results in soft 404s. Google drops unmapped pages from the index within 48 hours.

Build a deterministic mapping table.

Legacy URL PatternNew URL PatternStatus CodeNotes
/about-us.html/about301Static 1:1 mapping
/blog/category/[id]/blog/tags/[slug]301Requires DB lookup for ID to Slug
/products?sku=[id]/shop/[id]301Query param to path variable
/old-feature/410Feature deprecated. Use 410 Gone, not 301.

If a feature no longer exists in the new app, return a 410 Gone status code. Do not redirect deprecated pages to the homepage. Blanket homepage redirects confuse search engines, dilute link equity, and artificially inflate your bounce rate.

How do you implement 301 redirects in Next.js?

You implement 301 redirects in Next.js by defining an array of redirect objects in next.config.js for static routes, or by using Middleware for dynamic routing rules that require parameter extraction.

Next.js offers two distinct APIs for redirects. Use next.config.js for static, known paths. This executes at the build level and is highly performant.

// next.config.js
module.exports = {
  async redirects() {
    return [
      {
        source: '/about-us.html',
        destination: '/about',
        permanent: true, // Triggers a 301
      },
      {
        source: '/articles/:slug*',
        destination: '/blog/:slug*',
        permanent: true,
      },
    ]
  },
}

For complex pattern matching—like converting query parameters to path segments—use Next.js Middleware. Middleware executes on the Edge before a request completes, ensuring the redirect resolves in under 50ms. Fast redirects prevent crawl budget waste.

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export function middleware(request: NextRequest) {
  const url = request.nextUrl
  
  // Intercept legacy query-based product URLs
  if (url.pathname === '/product.php' && url.searchParams.has('id')) {
    const id = url.searchParams.get('id')
    return NextResponse.redirect(new URL(`/shop/${id}`, request.url), 301)
  }
 
  return NextResponse.next()
}
 
export const config = {
  matcher: '/product.php',
}

How do you preserve metadata during a component rewrite?

You preserve metadata by strictly copying the exact string values of titles, descriptions, and canonical tags from your baseline into your new framework's metadata API.

The Next.js App Router handles metadata entirely server-side via the generateMetadata API. This is objectively superior to the old Pages Router next/head component because it natively merges hierarchical tags, preventing duplicate <title> or <meta name="description"> tags from leaking into the DOM.

When rewriting your route segments, ensure your canonical URL logic explicitly handles trailing slashes. A mismatch between https://app.com/blog and https://app.com/blog/ will split your indexing signals.

// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
 
type Props = {
  params: { slug: string }
}
 
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await fetchPost(params.slug)
  
  return {
    title: post.legacyTitle, // Exact match from current-state.json
    description: post.legacyDescription,
    alternates: {
      canonical: `https://yourdomain.com/blog/${params.slug}`,
    },
    openGraph: {
      images: [
        {
          url: post.ogImageUrl,
          width: 1200,
          height: 630,
        },
      ],
    },
  }
}

Do not conditionally render metadata based on client-side state. If your tags rely on useEffect, Googlebot will index a blank page on its initial HTML pass. The App Router enforces server-side metadata generation, which guards against this class of error.

How do you validate the migration before shipping?

You validate the migration by running a diff against your baseline JSON snapshot in your CI/CD pipeline, failing the build if critical SEO tags are missing or altered.

Human review fails at scale. You cannot manually check 500 pages to ensure the canonical tags match the staging environment. You automate this.

Indxel provides a --diff flag that compares your local build against the current-state.json baseline you generated in Step 1. It runs 15 rules covering title length (50-60 chars), description presence, og:image HTTP status, canonical URL resolution, and JSON-LD validity.

Add this step to your GitHub Actions workflow:

# .github/workflows/seo-validation.yml
name: SEO Migration Gate
on: [pull_request]
 
jobs:
  validate-seo:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Start staging server
        run: npm run build && npm run start &
        
      - name: Run Indxel Diff
        run: npx indxel check http://localhost:3000 --ci --diff current-state.json

When a developer accidentally deletes the description prop from a shared layout component, the CLI catches it and outputs warnings in the same format as ESLint—one line per issue, with the file path and rule ID.

$ npx indxel check http://localhost:3000 --ci --diff current-state.json
 
Running diff against current-state.json...
Crawled 47/47 pages in 3.2s.
 
❌ 3 critical errors found:
/shop/category-a  - Missing canonical URL (was: https://legacy.com/shop/category-a)
/shop/category-b  - Title mismatch. Expected "Category B - Shop", got "Category B"
/blog/post-123    - og:image returns 404 Not Found
 
✖ Build failed. 44/47 pages pass. Fix 3 errors to merge.

A typical Next.js app with 50 pages takes 3 seconds to validate. That adds 3 seconds to your build time but saves hours of manual review and prevents catastrophic traffic drops.

The diff command allows a 5% variance threshold by default to account for minor whitespace or encoding differences. You can enforce strict 100% matching by passing the --strict flag.

How do you handle sitemaps and indexing post-launch?

You handle indexing post-launch by shipping an updated XML sitemap containing only the new 200 OK URLs and immediately submitting it to Google Search Console.

Your new sitemap must not contain any 301 redirects or 404 pages. It should exclusively list the final destination URLs of your new framework.

In Next.js, generate this dynamically using sitemap.ts.

// app/sitemap.ts
import { MetadataRoute } from 'next'
 
export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://yourdomain.com',
      lastModified: new Date(),
      changeFrequency: 'yearly',
      priority: 1,
    },
    {
      url: 'https://yourdomain.com/about',
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.8,
    },
    // Map over your dynamic routes here
  ]
}

Once the DNS propagates for your new application, log into Google Search Console. Submit the new sitemap.xml URL. Do not use the "Request Indexing" tool on individual pages unless they are the homepage or a critical hub. The sitemap submission will trigger a natural recrawl of your architecture.

How long should you monitor indexation?

You must monitor indexation daily for 30 days post-migration to catch trailing 404 errors, crawl spikes, and canonicalization drops.

Traffic volatility is guaranteed during a migration. Expect a 10-15% fluctuation in impressions for the first 7 days as Google updates its index with your new DOM structure and redirect paths.

Monitor the "Pages" report in Google Search Console. Look specifically for two error categories:

  1. Page with redirect: This number should spike. This confirms Google has found your 301s.
  2. Not found (404): This number should remain flat. If it spikes, your routing table from Step 2 is incomplete.

If you see a spike in "Duplicate without user-selected canonical," your Next.js generateMetadata implementation is failing to inject the <link rel="canonical"> tag on dynamic routes. Re-run indxel check locally to isolate the missing tags.

Frequently Asked Questions

Do I need to keep 301 redirects forever?

Keep 301 redirects active for at least 12 months, though leaving them permanently is the safest approach for aged domains. Google takes several months to fully pass PageRank from the legacy URL to the new URL. Since next.config.js redirects add zero overhead to your runtime performance, there is no technical reason to delete them.

Does moving from client-side rendering (SPA) to SSR improve SEO?

Moving to SSR drastically reduces Googlebot's JavaScript rendering overhead, ensuring your metadata and content are indexed on the first crawl pass rather than the deferred rendering queue. If you are migrating from Create React App to Next.js App Router, your time-to-index will drop from weeks to days.

How do I test redirects locally?

You test redirects locally by running your production build and using a tool like curl -I or the Indxel CLI to verify the HTTP 301 status code before deployment. Do not test redirects in next dev mode, as the development server handles routing differently than the compiled Edge network or Node.js server.

How do I handle i18n routing during a migration?

You handle i18n routing migrations by explicitly mapping your legacy locale strategy (e.g., subdomains like fr.domain.com) to your new strategy (e.g., subpaths like domain.com/fr) and ensuring the hreflang tags in your <head> match the new destination URLs exactly.


npx indxel init