1. Introducción contextual
WordPress sigue dominando el mercado de gestión de contenidos: según W3Techs, más del 43 % de los sitios web usan WordPress como CMS. Sin embargo, la arquitectura monolítica “todo‑en‑uno” que combina el front‑end PHP con la capa de datos se vuelve cada vez más limitante cuando se persiguen objetivos como rendimiento de primera página (Core Web Vitals), personalización avanzada o integración con micro‑servicios.
En los últimos años, la tendencia headless ha ganado tracción porque permite desacoplar la capa de presentación del motor de contenidos. Los editores siguen trabajando en el familiar panel de WordPress, mientras que los desarrolladores pueden usar React, Next.js y TypeScript para crear experiencias web modernas, rápidas y seguras.
El momento de migrar a una arquitectura headless es crítico por tres razones:
- Experiencia de usuario – Google penaliza tiempos de carga > 2 s; los sitios estáticos generados por Next.js pueden servir contenido en < 500 ms desde la CDN.
- Escalabilidad – Un front‑end desacoplado puede escalar horizontalmente sin afectar la base de datos de WordPress.
- Flexibilidad tecnológica – Permite combinar WordPress con otras fuentes (CRM, e‑commerce, IA) mediante APIs unificadas.
Este artículo muestra, de forma exhaustiva y práctica, cómo migrar un proyecto WordPress tradicional a una solución headless basada en Next.js, cubriendo arquitectura, implementación, casos de negocio, performance y los errores más comunes que se deben evitar.
2. Fundamentos y Arquitectura
2.1 ¿Qué es una arquitectura headless?
Una arquitectura headless separa head (capa de presentación) de body (capa de contenidos). WordPress actúa únicamente como API (REST o GraphQL) que expone entradas, páginas, taxonomías y metadatos. El front‑end, construido con Next.js, consume esas APIs y genera HTML estático (SSG) o renderizado en tiempo real (SSR/ISR).
2.2 Decisiones de diseño clave
| Decisión | Ventaja | Desventaja |
|---|---|---|
REST API nativa ( /wp-json/wp/v2/ ) | Simplicidad, sin plugins extra. | Menor flexibilidad en consultas complejas. |
| WPGraphQL + Apollo | Consultas declarativas, evita over‑fetching. | Necesita plugin y gestión de schema. |
| ISR (Incremental Static Regeneration) | Contenido estático con actualizaciones en tiempo real. | Complejidad en preview y revalidación. |
| Server‑Side Rendering (SSR) | SEO inmediato y datos siempre frescos. | Mayor carga en el servidor, latencia. |
2.3 Diagrama conceptual (descrito)
- WordPress (CMS) – ejecuta PHP, base de datos MySQL, plugins (WPGraphQL, JWT Auth).
- API Layer – expone endpoints
/wp-json/wp/v2/y/graphql. - Next.js – proyecto TypeScript con rutas bajo
/pagesy/app. UtilizagetStaticProps,getServerSidePropsyrevalidate. - CDN (Vercel/Netlify) – sirve los archivos estáticos generados, gestiona edge‑caching.
- Cache externa (Redis / Vercel Edge Config) – almacena respuestas de API para reducir latencia.
3. Implementación práctica
A continuación se muestra una migración paso a paso, partiendo de un enfoque naïve y evolucionando hacia una arquitectura robusta y optimizada. Cada fragmento de código contiene más de 15 líneas y está listo para copiarse a un proyecto de producción.
3.1 Configuración inicial del proyecto
# 1. Crear proyecto Next.js con TypeScript
npx create-next-app@latest wp-headless --typescript
# 2. Instalar dependencias para consumir APIs
cd wp-headless
npm i axios swr graphql @apollo/client @apollo/react-hooks
Nota:
axiosserá usado en la primera versión; más adelante lo sustituiremos porfetch+SWRy porApollocuando usemos GraphQL.
3.2 Consumo básico del REST API (versión naïve)
// pages/index.tsx
import React, { useEffect, useState } from 'react';
import axios from 'axios';
interface Post {
id: number;
title: { rendered: string };
excerpt: { rendered: string };
}
/**
* Renderiza una lista de posts obtenidos directamente del endpoint REST.
* No hay manejo de errores ni tipado estricto.
*/
const HomePage: React.FC = () => {
const [posts, setPosts] = useState<Post[]>([]);
useEffect(() => {
// Llamada directa al endpoint público de WordPress
axios
.get<Post[]>('https://example.com/wp-json/wp/v2/posts?_embed')
.then((res) => setPosts(res.data))
.catch((err) => console.error('Error al cargar posts', err));
}, []);
return (
<main>
<h1>Blog Headless</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;
Problemas detectados:
- Llamada en cliente → SEO pobre (Google no indexa contenido).
- No hay tipado de error → posibles runtime crashes.
- Cada visita dispara la misma petición → latencia y carga innecesaria al servidor WP.
3.3 Refactor a capa de servicios con Repository pattern
// src/services/wordpress.ts
import fetch from 'node-fetch';
/**
* Tipo que representa la respuesta mínima de un post.
*/
export interface WPPost {
id: number;
slug: string;
title: string;
excerpt: string;
featuredImage?: string;
}
/**
* Clase singleton que encapsula la lógica de consumo del API REST.
* Implementa el patrón Repository para desacoplar la capa de datos.
*/
export class WordPressRepository {
private static instance: WordPressRepository;
private readonly baseUrl: string;
private constructor(baseUrl: string) {
this.baseUrl = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
}
/** Obtiene la única instancia del repositorio */
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;
}
/** Convierte el objeto crudo de WP a WPPost tipado */
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,
};
}
/** Obtiene los últimos N posts con manejo de errores */
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 (versión mejorada)
import { GetStaticProps, NextPage } from 'next';
import Image from 'next/image';
import { WordPressRepository, WPPost } from '../src/services/wordpress';
interface HomeProps {
posts: WPPost[];
}
/**
* Genera la página estática en build time (SSG).
* SEO garantizado y cero carga al cliente para el listado inicial.
*/
export const getStaticProps: GetStaticProps<HomeProps> = async () => {
const repo = WordPressRepository.getInstance();
const posts = await repo.getLatestPosts(6);
return { props: { posts }, revalidate: 60 }; // ISR cada 60 s
};
const HomePage: NextPage<HomeProps> = ({ posts }) => (
<main>
<h1>Blog Headless con 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;
Ventajas:
- SSG + ISR → contenido indexable y actualizado cada minuto.
- Singleton Repository → reutiliza la configuración y evita múltiples instancias.
- Tipado estricto → errores detectados en compilación.
3.4 Optimización con SWR y revalidación en cliente
// src/hooks/usePosts.ts
import useSWR, { SWRConfiguration } from 'swr';
import { WPPost, WordPressRepository } from '../services/wordpress';
/**
* SWR fetcher que delega al repositorio.
* Se usa en componentes que requieren datos frescos sin recargar la página.
*/
const fetcher = async (): Promise<WPPost[]> => {
const repo = WordPressRepository.getInstance();
return repo.getLatestPosts(4);
};
/**
* Hook reutilizable con configuración de revalidación cada 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>Cargando últimos artículos…</p>;
if (isError) return <p>Error al cargar los artículos.</p>;
return (
<section>
<h2>Últimos artículos</h2>
<button onClick={() => refresh()}>Refrescar</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>
);
};
Por qué SWR:
- Cache automático → evita peticiones redundantes.
- Revalidación en background → UI siempre fresca sin bloquear al usuario.
- Fácil manejo de estados (
isLoading,isError).
3.5 Implementación de Preview Mode (para editores)
// pages/api/preview.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { WordPressRepository } from '../../src/services/wordpress';
/**
* Habilita preview mode cuando WordPress envía la cookie `wp-preview`.
* Solo accesible para usuarios autenticados (ejemplo simplificado).
*/
export default async function preview(req: NextApiRequest, res: NextApiResponse) {
const token = req.query.token as string;
if (!token) {
return res.status(401).json({ message: 'Token inválido' });
}
// Validar token contra WP (ejemplo simplificado)
const repo = WordPressRepository.getInstance();
try {
// Suponemos que la API tiene un endpoint /preview-auth que verifica el JWT
const authRes = await fetch(`${process.env.WP_API_URL}/preview-auth?token=${token}`);
if (!authRes.ok) throw new Error('Auth failed');
// Activar preview mode de Next.js
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: 'No autorizado' });
}
}
// pages/[slug].tsx (uso de 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];
