Volver al blogGuías

Cómo montar WordPress headless con Next.js (guía paso a paso)

WordPress como CMS, Next.js como frontend. Tu cliente sigue editando en wp-admin y los visitantes reciben una web que carga en menos de 2 segundos. Guía técnica completa con código real: REST API, ISR, rutas dinámicas, imágenes y SEO.

FR
Francisco Reyes
CDO · Director Digital Externo
16 de julio de 202614 min de lectura

Hace unos meses me llegó un caso típico: una empresa con un WordPress de años, cargado de plugins y con un tema comprado que nadie sabía tocar — el punto de partida de casi todos los errores de WordPress que cuestan dinero. El equipo de marketing publicaba contenido sin problemas — wp-admin les funcionaba perfecto — pero la web tardaba 5 segundos en cargar en móvil y Lighthouse la castigaba sin piedad. La pregunta que me hicieron fue la de siempre: "¿tenemos que migrar todo y perder el CMS?".

Desarrollador trabajando de noche en la arquitectura de un WordPress headless con Next.js, con un diagrama en papel junto al portátil

No. Existe un camino intermedio que uso con clientes cuando el caso lo justifica: WordPress headless. El equipo sigue editando en el WordPress de siempre, pero los visitantes reciben una web construida con Next.js — rápida, moderna y sin cargar ni un solo plugin de WordPress en el navegador.

En esta guía te muestro cómo montarlo paso a paso, con el código real que uso. Y también te digo, con honestidad, cuándo NO conviene hacerlo — porque no es para todo el mundo.

En pocas palabras: al terminar tendrás un WordPress funcionando como CMS puro (el equipo edita igual que siempre) y un frontend Next.js que consume el contenido vía REST API, con regeneración incremental (ISR), rutas dinámicas por slug, imágenes optimizadas y SEO. Tiempo estimado: 4–8 horas si ya conoces React. Nivel: intermedio-avanzado (necesitas saber JavaScript/TypeScript y algo de React).

Qué es headless y por qué importa

En un WordPress tradicional, el mismo sistema hace dos trabajos: gestiona el contenido (backend) y genera el HTML que ve el visitante (frontend, con el tema y los plugins). "Headless" significa cortarle la cabeza: WordPress conserva solo el trabajo de gestionar contenido, y el frontend lo construye otra tecnología — en nuestro caso, Next.js — que consume ese contenido a través de la REST API.

El resultado práctico:

  • El equipo no cambia nada. Wp-admin, el editor de bloques, los usuarios, todo sigue igual.
  • El visitante recibe HTML estático o pre-renderizado. Sin PHP ejecutándose por visita, sin 30 plugins cargando CSS y JS.
  • El WordPress puede vivir escondido en un subdominio tipo cms.tudominio.com, fuera de la vista (y del alcance de bots que escanean /wp-login.php en el dominio principal).

En mi experiencia, sitios que pasaban de 4–6 segundos de LCP con un tema WordPress pesado bajan a menos de 1,5 segundos con este enfoque. No es magia: es que dejas de servir el peso muerto de un tema multipropósito y sirves solo lo que la página necesita.

Antes de empezar

Requisitos:

  • Un WordPress funcionando (5.0 o superior; idealmente actualizado), con acceso de administrador.
  • Node.js 18.18 o superior en tu máquina.
  • Conocimientos de React/TypeScript. Esta guía no enseña React desde cero.
  • Un lugar donde desplegar Next.js: Vercel, un VPS, o un hosting con soporte Node.js.

Cuándo NO conviene headless (léelo antes de invertir un fin de semana):

  • Si tu blog o web funciona bien y carga razonable, no lo hagas. Headless resuelve un problema de rendimiento y flexibilidad; si no tienes ese problema, solo estás añadiendo complejidad.
  • Si no hay nadie técnico en el equipo (o presupuesto para tenerlo), no. Con WordPress tradicional, cualquier agencia o freelancer puede tocar la web. Con headless, cada cambio visual requiere alguien que sepa React. Es un compromiso a largo plazo.
  • Si el presupuesto es mínimo, tampoco. Ahora mantienes dos sistemas: el WordPress (hosting, actualizaciones, seguridad) y la app Next.js (deploy, dependencias, builds). Dos cosas que pueden romperse en vez de una.
  • Si dependes fuerte de plugins de frontend — page builders tipo Elementor, formularios complejos, WooCommerce con checkout — cada uno de esos tendrás que reconstruirlo en Next.js. Suma ese coste antes de decidir.

Si estás evaluando la decisión de fondo (¿WordPress a secas, headless, o Next.js puro?), escribí una comparativa completa en WordPress vs Next.js para empresas. Esta guía asume que ya decidiste que headless es tu camino.

Paso 1: Preparar WordPress como CMS

1.1 Activa los enlaces permanentes correctos

En Ajustes → Enlaces permanentes, selecciona "Nombre de la entrada". Esto no es cosmético: la REST API de WordPress necesita permalinks "bonitos" para funcionar bien, y además usaremos el slug de cada post como identificador en las rutas de Next.js.

1.2 Comprueba que la REST API responde

WordPress trae la REST API activada por defecto desde la versión 4.7. Verifícalo abriendo en el navegador:

https://cms.tudominio.com/wp-json/wp/v2/posts

Deberías ver un JSON con tus posts. Si ves un error 404, revisa los permalinks (paso anterior). Si ves un 403, algún plugin de seguridad está bloqueando la API — busca en su configuración una opción tipo "REST API access" y permite al menos lectura pública de posts.

1.3 CORS (solo si harás fetch desde el navegador)

Si todo el fetch de contenido ocurre en el servidor de Next.js (que es lo que haremos en esta guía, con Server Components), no necesitas configurar CORS: las peticiones servidor-a-servidor no pasan por la política de CORS del navegador.

Solo si en algún momento necesitas que el navegador del visitante consulte la API directamente (por ejemplo, un buscador en vivo), añade las cabeceras en el WordPress. En el functions.php del tema (o mejor, en un plugin propio):

add_action( 'rest_api_init', function () {
    remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
    add_filter( 'rest_pre_serve_request', function ( $value ) {
        header( 'Access-Control-Allow-Origin: https://tudominio.com' );
        header( 'Access-Control-Allow-Methods: GET' );
        return $value;
    } );
}, 15 );

Fíjate que permito solo el dominio del frontend y solo GET. Nada de Access-Control-Allow-Origin: * en producción.

1.4 (Opcional) Mueve el WordPress a un subdominio

Si estás montando esto desde cero, instala el WordPress en cms.tudominio.com y deja tudominio.com para el frontend Next.js. Ventajas: el CMS queda fuera de la superficie visible, puedes protegerlo por IP o con autenticación básica en el wp-admin, y la separación queda limpia desde el día uno.

Paso 2: Crear la app Next.js y hacer el primer fetch

2.1 Crea el proyecto

npx create-next-app@latest mi-frontend --typescript --app --tailwind
cd mi-frontend

Acepta las opciones por defecto (App Router incluido). Guarda la URL del CMS en .env.local:

# .env.local
WORDPRESS_API_URL=https://cms.tudominio.com/wp-json/wp/v2

2.2 Define los tipos y la capa de datos

Crea src/lib/wordpress.ts. Centralizar aquí todo el acceso a la API te ahorrará dolores cuando quieras cambiar algo:

// src/lib/wordpress.ts

export interface WPPost {
  id: number;
  slug: string;
  date: string;
  title: { rendered: string };
  excerpt: { rendered: string };
  content: { rendered: string };
  _embedded?: {
    'wp:featuredmedia'?: Array<{
      source_url: string;
      alt_text: string;
      media_details?: { width: number; height: number };
    }>;
    author?: Array<{ name: string }>;
  };
}

const API_URL = process.env.WORDPRESS_API_URL!;

export async function getPosts(perPage = 10): Promise<WPPost[]> {
  const res = await fetch(
    `${API_URL}/posts?_embed&per_page=${perPage}`,
    { next: { revalidate: 300 } } // ISR: revalida cada 5 minutos
  );

  if (!res.ok) {
    throw new Error(`Error al obtener posts: ${res.status}`);
  }

  return res.json();
}

export async function getPostBySlug(slug: string): Promise<WPPost | null> {
  const res = await fetch(
    `${API_URL}/posts?slug=${slug}&_embed`,
    { next: { revalidate: 300 } }
  );

  if (!res.ok) return null;

  const posts: WPPost[] = await res.json();
  return posts[0] ?? null;
}

Dos detalles importantes:

  • _embed hace que la API incluya la imagen destacada y el autor en la misma respuesta, en lugar de obligarte a hacer una petición extra por cada recurso.
  • next: { revalidate: 300 } activa ISR (Incremental Static Regeneration): Next.js cachea la respuesta y la regenera como máximo cada 5 minutos. El equipo publica en WordPress y, en pocos minutos, el cambio aparece en la web — sin rebuilds manuales. Ajusta el número a tu ritmo de publicación.

2.3 El listado del blog (Server Component)

En App Router, los componentes son Server Components por defecto: el fetch ocurre en el servidor, el visitante recibe HTML ya renderizado. Crea src/app/blog/page.tsx:

// src/app/blog/page.tsx
import Link from 'next/link';
import { getPosts } from '@/lib/wordpress';

export const metadata = {
  title: 'Blog',
  description: 'Artículos y guías del equipo.',
};

export default async function BlogPage() {
  const posts = await getPosts(10);

  return (
    <main className="mx-auto max-w-3xl px-4 py-12">
      <h1 className="mb-8 text-3xl font-bold">Blog</h1>
      <ul className="space-y-8">
        {posts.map((post) => (
          <li key={post.id}>
            <Link href={`/blog/${post.slug}`}>
              <h2
                className="text-xl font-semibold"
                dangerouslySetInnerHTML={{ __html: post.title.rendered }}
              />
            </Link>
            <div
              className="mt-2 text-gray-600"
              dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }}
            />
          </li>
        ))}
      </ul>
    </main>
  );
}

Sí, dangerouslySetInnerHTML incomoda la primera vez. WordPress devuelve los títulos y el contenido como HTML renderizado (con entidades, etiquetas, etc.), así que es la forma estándar de pintarlo. El contenido viene de tu propio CMS, con autores de confianza; si en tu caso hay autores externos o contenido de terceros, sanitízalo antes con una librería como sanitize-html.

Paso 3: Rutas dinámicas por slug

Cada post necesita su propia URL: /blog/mi-primer-post. Crea src/app/blog/[slug]/page.tsx:

// src/app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPosts, getPostBySlug } from '@/lib/wordpress';

// Pre-genera las rutas de los posts existentes en build time
export async function generateStaticParams() {
  const posts = await getPosts(100);
  return posts.map((post) => ({ slug: post.slug }));
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  if (!post) notFound();

  return (
    <article className="prose mx-auto max-w-3xl px-4 py-12">
      <h1 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
      <div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
    </article>
  );
}

Qué hace cada pieza:

  • generateStaticParams le dice a Next.js qué slugs existen en el momento del build, para pre-renderizarlos como HTML estático. Los posts publicados después del build también funcionan: Next.js los genera bajo demanda en la primera visita y los cachea (con el revalidate que ya definimos).
  • params es una Promise en las versiones recientes de Next.js (15+) — de ahí el await params. Si vienes de versiones anteriores, este es el cambio que suele romper los tutoriales viejos.
  • notFound() devuelve un 404 correcto si el slug no existe, en lugar de una página rota.

Paso 4: Imágenes con next/image

El HTML que devuelve WordPress trae etiquetas <img> apuntando al dominio del CMS. Para la imagen destacada (que controlas tú desde el JSON), usa next/image y gana optimización automática. Primero autoriza el dominio en next.config.ts:

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cms.tudominio.com',
        pathname: '/wp-content/uploads/**',
      },
    ],
  },
};

export default nextConfig;

Y en el componente del post:

import Image from 'next/image';

const media = post._embedded?.['wp:featuredmedia']?.[0];

{media && (
  <Image
    src={media.source_url}
    alt={media.alt_text || post.title.rendered}
    width={media.media_details?.width ?? 1200}
    height={media.media_details?.height ?? 630}
    priority
    className="rounded-lg"
  />
)}

Las imágenes dentro del contenido (content.rendered) seguirán siendo <img> normales. Para un blog, suele ser aceptable — WordPress ya les añade loading="lazy" y srcset. Si necesitas convertirlas todas a next/image, existe la vía de parsear el HTML con html-react-parser y reemplazar los nodos, pero es trabajo extra que solo recomiendo si las métricas lo piden.

Paso 5: SEO — metadata desde el post

Next.js genera las meta tags con generateMetadata. Añádelo al archivo de la ruta dinámica:

// src/app/blog/[slug]/page.tsx (añadir)
import type { Metadata } from 'next';

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPostBySlug(slug);

  if (!post) return { title: 'Post no encontrado' };

  // El excerpt viene con HTML; lo limpiamos para la description
  const description = post.excerpt.rendered
    .replace(/<[^>]+>/g, '')
    .trim()
    .slice(0, 160);

  const image = post._embedded?.['wp:featuredmedia']?.[0]?.source_url;

  return {
    title: post.title.rendered,
    description,
    alternates: {
      canonical: `https://tudominio.com/blog/${slug}`,
    },
    openGraph: {
      title: post.title.rendered,
      description,
      type: 'article',
      publishedTime: post.date,
      images: image ? [{ url: image }] : [],
    },
  };
}

Ojo con el canonical: como el WordPress sigue vivo en cms.tudominio.com, sus URLs originales existen y Google podría indexarlas duplicando tu contenido. Dos medidas: define el canonical apuntando al frontend (como arriba) y, en el WordPress, marca Ajustes → Lectura → Disuadir a los motores de búsqueda o bloquea el subdominio por robots.txt.

Si usas Yoast en el WordPress, sus datos SEO (título SEO, meta description que escribió el equipo) están disponibles en la REST API dentro del campo yoast_head_json de cada post — puedes leerlos en generateMetadata en lugar de derivarlos del excerpt. Es opcional, pero mantiene el control del SEO en manos de quien edita el contenido.

Paso 6: Deploy

Lo esencial en dos líneas: npm run build genera la app, y la despliegas donde tengas Node.js — Vercel es lo más directo (conectas el repo y listo), pero también funciona en un VPS o en un hosting compartido con soporte Node (cPanel + Passenger, por ejemplo, que es donde yo alojo proyectos de clientes con presupuesto acotado). Recuerda definir WORDPRESS_API_URL como variable de entorno en el servidor. El despliegue en cPanel tiene sus propias trampas (punto de entrada, reinicio de la aplicación, compilaciones) y las cubro por separado en cómo desplegar Next.js en cPanel.

Problemas comunes

La API devuelve posts pero sin _embedded. Te faltó el parámetro ?_embed en la URL. Sin él, la imagen destacada llega solo como un ID numérico.

Publico en WordPress y el cambio no aparece. Es el ISR haciendo su trabajo: espera el tiempo de revalidate. Si necesitas actualización inmediata, monta un endpoint con revalidatePath() en Next.js y llámalo desde un webhook de WordPress (el plugin WP Webhooks sirve) al publicar.

Error de imagen: "hostname not configured". Falta el dominio del CMS en remotePatterns de next.config.ts. Reinicia el dev server después de cambiarlo — el config no se recarga en caliente.

El build falla porque el WordPress no responde. generateStaticParams corre en build time; si el CMS está caído o bloquea la IP del servidor de build, el build muere. Envuelve el fetch en try/catch devolviendo [] como fallback: las rutas se generarán bajo demanda.

Los enlaces internos del contenido apuntan a cms.tudominio.com. Los escribió el editor con la URL del CMS. Solución rápida: un .replaceAll('https://cms.tudominio.com', 'https://tudominio.com') sobre content.rendered antes de renderizar.

Preguntas frecuentes

¿Puedo usar WPGraphQL en vez de la REST API? Sí, y para proyectos grandes con queries complejas tiene ventajas (pides exactamente los campos que necesitas). Pero exige instalar un plugin y aprender GraphQL. La REST API viene de serie y para un blog o web corporativa es más que suficiente. Empieza por REST; migra a GraphQL solo si te encuentras haciendo malabares con múltiples peticiones encadenadas.

¿Qué pasa con los formularios de contacto y los comentarios? Los pierdes del lado WordPress: eran parte del frontend que acabas de reemplazar. Los formularios se reconstruyen en Next.js (un Server Action o una ruta de API que envía el correo o guarda el contacto). Los comentarios, si los necesitas, puedes seguir enviándolos a WordPress vía la propia REST API (/wp-json/wp/v2/comments) o usar un servicio externo. Presupuesta este trabajo antes de empezar — es el coste oculto más habitual.

¿Vale la pena para una web de 5 páginas que casi no cambia? Honestamente, no. Si el contenido casi no cambia, no necesitas CMS: hazla directamente en Next.js (o incluso deja el WordPress que ya tienes si funciona). Headless brilla cuando hay un equipo publicando contenido con frecuencia Y el rendimiento del frontend importa de verdad. Si dudas de si necesitas siquiera este nivel de desarrollo, te puede servir cuándo necesitas una app y cuándo no — la lógica de decisión es la misma.


Así es como monto WordPress headless con clientes: el equipo de contenido no nota el cambio, y el visitante sí. Si prefieres que esto lo hagamos juntos, una consultoría 1:1 de $60 y salimos con el plan.

WordPress headlessNext.jsREST APIdesarrollo webrendimiento webISRarquitectura web

Newsletter

¿Te fue útil este artículo?

Suscríbete y te aviso cuando publique algo que valga la pena leer. Sin correo basura y sin una frecuencia forzada.

Responsable: Francisco Reyes (FARP). Uso tus datos para enviarte la newsletter y para nada más; los guardo hasta que te des de baja y no se los cedo a nadie. Puedes pedirme acceso, corrección o borrado cuando quieras escribiendo a francisco@laura.lat. Detalle completo en la Política de Privacidad.

FR
Francisco Reyes
Director Digital Externo · 11 años alineando marketing, ventas y tecnología en PYMEs de Chile, España y LATAM. Creador de FARP.
Conversar sobre esto

¿Una duda rápida?

Escríbeme y te contesto en menos de 24 horas hábiles.