Assessing migration feasibility

Before migrating a classic WordPress site to a headless architecture, you need to assess whether the project is suited to this transition. Not all sites benefit from decoupling, and certain configurations make the migration more costly than the expected gain.

Criteria favoring migration

  • The site has high performance requirements (Core Web Vitals, load time)
  • Content needs to be distributed across multiple channels (website, mobile app, kiosks)
  • The current design is constrained by the WordPress theme
  • The development team is proficient with React / Next.js
  • The site mainly uses structured content (posts, pages, CPTs with ACF)

Unfavorable criteria

  • The site relies heavily on plugins that generate frontend HTML (page builders, complex sliders, forms with advanced logic)
  • The technical team has no JavaScript frontend skills
  • The budget does not cover custom frontend development
  • The site has no performance issue and no multi-channel needs

Migration is not mandatory

Headless is an architecture, not an obligation. A well-optimized classic WordPress site with a lightweight theme can achieve excellent performance. Migration is justified when monolithic limitations become a measurable obstacle to the project.

Phase 1: Audit and inventory of the existing site

The first phase consists of documenting the existing site in its entirety. This inventory serves as a reference throughout the migration.

Content inventory

List every content type: posts, pages, Custom Post Types, taxonomies (categories, tags, custom taxonomies). Count the volume of each type. Identify the custom fields (ACF, metadata) attached to each type.

Plugin inventory

Sort each active plugin into three categories: (A) backend plugins that remain compatible in headless (ACF, Yoast SEO, WooCommerce), (B) plugins whose functionality will need to be recreated on the frontend (sliders, contact forms, galleries), (C) plugins that are no longer useful (page cache plugins, theme CSS/JS minification).

URL mapping

Export the full list of URLs indexed by search engines (via Google Search Console or a Screaming Frog crawl). Each existing URL must have an equivalent or a 301 redirect in the new architecture.

Current performance baseline

Measure current Core Web Vitals (LCP, FID/INP, CLS) using PageSpeed Insights or Lighthouse. These measurements will serve as a comparison baseline to validate post-migration gains.

Plugin inventory template

| Plugin | Category | Action in headless | |--------|-----------|-------------------| | Advanced Custom Fields | A - Backend | Keep, expose via API/GraphQL | | Yoast SEO | A - Backend | Keep, fetch metadata via API | | Contact Form 7 | B - Frontend | Replace with a React form + API route | | Elementor | C - Useless | Remove, the Next.js frontend replaces it | | WP Super Cache | C - Useless | Remove, Next.js handles caching (SSG/ISR) | | WooCommerce | A - Backend | Keep, use the WooCommerce API | | Slider Revolution | B - Frontend | Replace with a React component |

Phase 2: Setting up the API layer

This phase prepares WordPress to operate as a headless backend without affecting the live site.

Install and configure API plugins

# Plugins to install on the existing WordPress
# WPGraphQL (if you choose GraphQL)
# WPGraphQL for ACF (to expose ACF fields)
# Yoast SEO for WPGraphQL (for SEO metadata)

Verify content exposure

Test that all content types are accessible via the API:

# Check posts
curl https://your-site.com/wp-json/wp/v2/posts?per_page=1

# Check pages
curl https://your-site.com/wp-json/wp/v2/pages?per_page=1

# Check CPTs (if applicable)
curl https://your-site.com/wp-json/wp/v2/projets?per_page=1

# Check the GraphQL endpoint
curl -X POST https://your-site.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{ posts(first: 1) { nodes { title } } }"}'

Add missing CPTs to the API

If existing Custom Post Types don't have show_in_rest => true, fix their registration:

// Add show_in_rest to an existing CPT registered by a third-party plugin
add_filter('register_post_type_args', function ($args, $post_type) {
  if ($post_type === 'portfolio') {
    $args['show_in_rest'] = true;
    $args['rest_base'] = 'portfolios';
    $args['show_in_graphql'] = true;
    $args['graphql_single_name'] = 'portfolio';
    $args['graphql_plural_name'] = 'portfolios';
  }
  return $args;
}, 10, 2);

Phase 3: Building the Next.js frontend

The frontend is developed in parallel, on a separate staging domain, while the WordPress site continues to operate normally.

frontend/
├── app/
│   ├── layout.tsx              # Root layout
│   ├── page.tsx                # Home page
│   ├── blog/
│   │   ├── page.tsx            # Posts listing
│   │   └── [slug]/page.tsx     # Individual post
│   ├── [slug]/page.tsx         # WordPress pages
│   └── projets/
│       ├── page.tsx            # Projects listing (CPT)
│       └── [slug]/page.tsx     # Individual project
├── lib/
│   ├── wordpress.ts            # API query functions
│   └── types.ts                # TypeScript types
├── components/
│   ├── Header.tsx
│   ├── Footer.tsx
│   └── ...
└── next.config.js

Rebuild pages one by one

Proceed page by page, starting with the simplest:

  1. Static pages (About, Contact, Legal Notice)
  2. Posts listing and individual post
  3. Taxonomy pages (categories, tags)
  4. Custom Post Types and their archives
  5. Home page (often the most complex)
  6. Interactive features (search, forms, filters)

Phase 4: URL mapping and redirects

Preserving existing URLs is critical for SEO. Each indexed URL must either keep the same path in the new architecture or be redirected via a 301 status code.

Never break existing URLs

A URL changed without a 301 redirect loses its ranking history. Search engines treat the new URL as an entirely new page. If the site has well-ranking pages, the traffic loss can be significant and slow to recover.

URL structure mapping

| WordPress URL | Next.js URL | Action | |------|------|--------| | /2024/11/my-article/ | /blog/my-article | 301 redirect | | /category/technology/ | /blog/categorie/technology | 301 redirect | | /about/ | /about | Keep (same path) | | /wp-content/uploads/... | Unchanged (same server) | Proxy or CDN |

Implement redirects in Next.js

// next.config.js
const nextConfig = {
  async redirects() {
    return [
      // Redirect WordPress date format to the new format
      {
        source: '/:year(\\d{4})/:month(\\d{2})/:slug',
        destination: '/blog/:slug',
        permanent: true, // 301
      },
      // Redirect categories
      {
        source: '/category/:slug',
        destination: '/blog/categorie/:slug',
        permanent: true,
      },
      // Redirect tags
      {
        source: '/tag/:slug',
        destination: '/blog/tag/:slug',
        permanent: true,
      },
      // Redirect the RSS feed
      {
        source: '/feed',
        destination: '/rss.xml',
        permanent: true,
      },
    ];
  },
};

Phase 5: Preserving SEO

Metadata and meta tags

Fetch SEO data from Yoast SEO or Rank Math via the API:

query ArticleSEO($slug: ID!) {
  post(id: $slug, idType: SLUG) {
    title
    seo {
      title
      metaDesc
      canonical
      opengraphTitle
      opengraphDescription
      opengraphImage {
        sourceUrl
      }
      twitterTitle
      twitterDescription
    }
  }
}

XML sitemap

Generate the sitemap dynamically in Next.js by fetching all URLs from the WordPress API:

// app/sitemap.ts
import { MetadataRoute } from 'next';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await fetchAllPosts();
  const pages = await fetchAllPages();

  const postUrls = posts.map((post) => ({
    url: `https://your-site.com/blog/${post.slug}`,
    lastModified: new Date(post.modified),
    changeFrequency: 'weekly' as const,
    priority: 0.7,
  }));

  const pageUrls = pages.map((page) => ({
    url: `https://your-site.com/${page.slug}`,
    lastModified: new Date(page.modified),
    changeFrequency: 'monthly' as const,
    priority: 0.8,
  }));

  return [
    { url: 'https://your-site.com', priority: 1.0 },
    ...pageUrls,
    ...postUrls,
  ];
}

Phase 6: DNS cutover and going live

Zero-downtime cutover strategy

  1. Deploy the Next.js frontend to its final hosting (Vercel, Node.js server, etc.)
  2. Configure the domain: the main domain points to the Next.js frontend. The WordPress backend is accessible via a subdomain (admin.your-site.com or cms.your-site.com).
  3. Lower the DNS TTL to 300 seconds (5 minutes) at least 48 hours before the cutover. This speeds up propagation of the change.
  4. Switch the DNS records: change the A record (or CNAME) to point to the Next.js server.
  5. Verify the SSL certificate: ensure the HTTPS certificate is active on the new server before the cutover.

Rollback plan

If a critical issue is detected after the cutover:

  1. Restore the previous DNS records (the lowered TTL allows propagation in 5 minutes)
  2. The original WordPress site immediately resumes traffic
  3. No content is lost since WordPress remains intact throughout the migration

The original WordPress remains intact

At no point during the migration is the original WordPress site modified destructively. API plugins are added, but the theme and frontend rendering keep working. Rollback simply means repointing DNS to the WordPress server.

Post-migration validation checklist

Common mistakes to avoid

#1

Broken URLs

Failing to map all old URLs to 301 redirects

#2

SEO forgotten

Failing to fetch Yoast/Rank Math metadata via the API

#3

Big bang migration

Migrating everything at once instead of going page by page

#4

No rollback

Failing to plan a rollback path in case of trouble