1. Contextual Introduction
WordPress continues to dominate the content‑management market: according to W3Techs, more than 43 % of websites use WordPress as a CMS. However, the monolithic “all‑in‑one” architecture that couples the PHP front‑end with the data layer becomes increasingly limiting when pursuing goals such as first‑page performance (Core Web Vitals), advanced personalization, or integration with micro‑services.
In recent years, the headless trend has gained traction because it allows the presentation layer to be decoupled from the content engine. Editors keep working in the familiar WordPress dashboard, while developers can use React, Next.js, and TypeScript to create modern, fast, and secure web experiences.
The moment to migrate to a headless architecture is critical for three reasons:
- User experience – Google penalizes load times > 2 s; static sites generated by Next.js can serve content in < 500 ms from the CDN.
- Scalability – A decoupled front‑end can scale horizontally without affecting the WordPress database.
- Technological flexibility – Allows WordPress to be combined with other sources (CRM, e‑commerce, AI) via unified APIs.
This article shows, exhaustively and practically, how to migrate a traditional WordPress project to a headless solution based on Next.js, covering architecture, implementation, business cases, performance, and the most common pitfalls to avoid.
2. Fundamentals and Architecture
2.1 What is a headless architecture?
A headless architecture separates head (presentation layer) from body (content layer). WordPress acts solely as an API (REST or GraphQL) that exposes posts, pages, taxonomies, and metadata. The front‑end, built with Next.js, consumes those APIs and generates static HTML (SSG) or renders in real time (SSR/ISR).
2.2 Key design decisions
| Decision | Advantage | Disadvantage |
|---|---|---|
Native REST API (/wp-json/wp/v2/) | Simplicity, no extra plugins. | Less flexibility for complex queries. |
| WPGraphQL + Apollo | Declarative queries, avoids over‑fetching. | Requires a plugin and schema management. |
| ISR (Incremental Static Regeneration) | Static content with real‑time updates. | Complexity in preview and revalidation. |
| Server‑Side Rendering (SSR) | Immediate SEO and always‑fresh data. | Higher server load, latency. |
2.3 Conceptual diagram (described)
- WordPress (CMS) – runs PHP, MySQL database, plugins (WPGraphQL, JWT Auth).
- API Layer – exposes endpoints
/wp-json/wp/v2/and/graphql. - Next.js – TypeScript project with routes under
/pagesand/app. UsesgetStaticProps,getServerSideProps, andrevalidate. - CDN (Vercel/Netlify) – serves generated static files, handles edge‑caching.
- External cache (Redis / Vercel Edge Config) – stores API responses to reduce latency.
3. Practical Implementation
Below is a step‑by‑step migration, starting from a naïve approach and evolving toward a robust, optimized architecture. Each code fragment contains more than 15 lines and is ready to be copied into a production project.
3.1 Initial project setup
# 1. Create a Next.js project with TypeScript
npx create-next-app@latest wp-headless --typescript
# 2. Install dependencies for consuming APIs
cd wp-headless
npm i axios swr graphql @apollo/client @apollo/react-hooks
Note:
axioswill be used in the first version; later we’ll replace it withfetch+SWRand withApollowhen we switch to GraphQL.
3.2 Basic REST API consumption (naïve version)
// pages/index.tsx
import React, { useEffect, useState } from 'react';
import axios from 'axios';
interface Post {
id: number;
title: { rendered: string };
excerpt: { rendered: string };
}
/**
* Renders a list of posts obtained directly from the REST endpoint.
* No error handling nor strict typing.
*/
const HomePage: React.FC = () => {
const [posts, setPosts] = useState<Post[]>([]);
useEffect(() => {
// Direct call to the public WordPress endpoint
axios
.get<Post[]>('https://example.com/wp-json/wp/v2/posts?_embed')
.then((res) => setPosts(res.data))
.catch((err) => console.error('Error loading posts', err));
}, []);
return (
<main>
<h1>Headless Blog</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<h2 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
<p dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }} />
</li>
))}
</ul>
</main>
);
};
export default HomePage;
Detected issues:
- Client‑side call → poor SEO (Google does not index the content).
- No error typing → possible runtime crashes.
- Every visit triggers the same request → latency and unnecessary load on the WP server.
3.3 Refactor to service layer with Repository pattern
// src/services/wordpress.ts
import fetch from 'node-fetch';
/**
* Type that represents the minimal response of a post.
*/
export interface WPPost {
id: number;
slug: string;
title: string;
excerpt: string;
featuredImage?: string;
}
/**
* Singleton class that encapsulates the REST API consumption logic.
* Implements the Repository pattern to decouple the data layer.
*/
export class WordPressRepository {
private static instance: WordPressRepository;
private readonly baseUrl: string;
private constructor(baseUrl: string) {
this.baseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
}
/** Retrieves the single repository instance */
public static getInstance(): WordPressRepository {
if (!WordPressRepository.instance) {
const WP_URL = process.env.WP_API_URL ?? 'https://example.com/wp-json/wp/v2';
WordPressRepository.instance = new WordPressRepository(WP_URL);
}
return WordPressRepository.instance;
}
/** Converts the raw WP object to a typed WPPost */
private mapPost(raw: any): WPPost {
const featured = raw._embedded?.['wp:featuredmedia']?.[0];
return {
id: raw.id,
slug: raw.slug,
title: raw.title.rendered,
excerpt: raw.excerpt.rendered,
featuredImage: featured?.source_url,
};
}
/** Retrieves the latest N posts with error handling */
public async getLatestPosts(limit = 5): Promise<WPPost[]> {
const url = `${this.baseUrl}posts?_embed&per_page=${limit}`;
const response = await fetch(url);
if (!response.ok) {
const msg = `WP API error ${response.status}: ${response.statusText}`;
throw new Error(msg);
}
const data = (await response.json()) as any[];
return data.map(this.mapPost);
}
}
// pages/index.tsx (improved version)
import { GetStaticProps, NextPage } from 'next';
import Image from 'next/image';
import { WordPressRepository, WPPost } from '../src/services/wordpress';
interface HomeProps {
posts: WPPost[];
}
/**
* Generates the static page at build time (SSG).
* SEO guaranteed and zero client load for the initial list.
*/
export const getStaticProps: GetStaticProps<HomeProps> = async () => {
const repo = WordPressRepository.getInstance();
const posts = await repo.getLatestPosts(6);
return { props: { posts }, revalidate: 60 }; // ISR every 60 s
};
const HomePage: NextPage<HomeProps> = ({ posts }) => (
<main>
<h1>Headless Blog with Next.js</h1>
<ul>
{posts.map((p) => (
<li key={p.id}>
<h2>{p.title}</h2>
{p.featuredImage && (
<Image src={p.featuredImage} alt={p.title} width={640} height={360} />
)}
<div dangerouslySetInnerHTML={{ __html: p.excerpt }} />
</li>
))}
</ul>
</main>
);
export default HomePage;
Advantages:
- SSG + ISR → indexable content that updates every minute.
- Singleton Repository → reuses configuration and avoids multiple instances.
- Strict typing → errors caught at compile time.
3.4 Optimization with SWR and client‑side revalidation
// src/hooks/usePosts.ts
import useSWR, { SWRConfiguration } from 'swr';
import { WPPost, WordPressRepository } from '../services/wordpress';
/**
* SWR fetcher that delegates to the repository.
* Used in components that need fresh data without a full page reload.
*/
const fetcher = async (): Promise<WPPost[]> => {
const repo = WordPressRepository.getInstance();
return repo.getLatestPosts(4);
};
/**
* Reusable hook with a revalidation interval of 30 s.
*/
export const usePosts = (options?: SWRConfiguration<WPPost[], Error>) => {
const { data, error, mutate } = useSWR<WPPost[], Error>('wp/latest', fetcher, {
revalidateOnFocus: false,
refreshInterval: 30_000,
...options,
});
return {
posts: data,
isLoading: !error && !data,
isError: error,
refresh: mutate,
};
};
// components/LatestPosts.tsx
import { FC } from 'react';
import Image from 'next/image';
import { usePosts } from '../hooks/usePosts';
export const LatestPosts: FC = () => {
const { posts, isLoading, isError, refresh } = usePosts();
if (isLoading) return <p>Loading latest articles…</p>;
if (isError) return <p>Error loading the articles.</p>;
return (
<section>
<h2>Latest articles</h2>
<button onClick={() => refresh()}>Refresh</button>
<ul>
{posts!.map((p) => (
<li key={p.id}>
<h3>{p.title}</h3>
{p.featuredImage && (
<Image src={p.featuredImage} alt={p.title} width={400} height={225} />
)}
<div dangerouslySetInnerHTML={{ __html: p.excerpt }} />
</li>
))}
</ul>
</section>
);
};
Why SWR:
- Automatic cache → avoids redundant requests.
- Background revalidation → UI stays fresh without blocking the user.
- Easy state handling (
isLoading,isError).
3.5 Preview Mode implementation (for editors)
// pages/api/preview.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { WordPressRepository } from '../../src/services/wordpress';
/**
* Enables preview mode when WordPress sends the `wp-preview` cookie.
* Only accessible to authenticated users (simplified example).
*/
export default async function preview(req: NextApiRequest, res: NextApiResponse) {
const token = req.query.token as string;
if (!token) {
return res.status(401).json({ message: 'Invalid token' });
}
// Validate token against WP (simplified example)
const repo = WordPressRepository.getInstance();
try {
// We assume the API has an endpoint /preview-auth that verifies the JWT
const authRes = await fetch(`${process.env.WP_API_URL}/preview-auth?token=${token}`);
if (!authRes.ok) throw new Error('Auth failed');
// Activate Next.js preview mode
res.setPreviewData({ wpToken: token });
const redirectUrl = req.query.slug ? `/${req.query.slug}` : '/';
return res.redirect(307, redirectUrl);
} catch (err) {
console.error('Preview auth error', err);
return res.status(403).json({ message: 'Not authorized' });
}
}
// pages/[slug].tsx (using preview data)
import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
import { WordPressRepository, WPPost } from '../src/services/wordpress';
import { useRouter } from 'next/router';
interface PostProps {
post: WPPost | null;
}
export const getStaticPaths: GetStaticPaths = async () => {
const repo = WordPressRepository.getInstance();
const posts = await repo.getLatestPosts(20);
const paths = posts.map((p) => ({ params: { slug: p.slug } }));
return { paths, fallback: 'blocking' };
};
export const getStaticProps: GetStaticProps<PostProps> = async (ctx) => {
const slug = ctx.params?.slug as string;
const repo = WordPressRepository.getInstance();
const preview = ctx.preview ?? false;
const endpoint = preview ? `posts?slug=${slug}&_embed` : `posts?slug=${slug}&_embed`;
const url = `${repo['baseUrl']}${endpoint}`;
const res = await fetch(url);
if (!res.ok) return { notFound: true };
const data = (await res.json()) as any[];
const raw = data[0];
