Tutorial de Implementación v1.3.1
Cómo construir una landing page consumiendo la REST API de WordPress con WP Headless Mode. Ejemplos completos en Next.js, Astro, Nuxt y Vanilla JS.
Prerrequisitos
Lista de verificación:
- WordPress 6.0+ con WP Headless Mode activo
- CORS configurado en Configuración → Orígenes CORS permitidos con la URL de tu frontend (ej:
http://localhost:3000para desarrollo) - Al menos algunos posts publicados en WordPress
- Node.js 18+ instalado localmente
En todos los ejemplos usaremos https://tu-wp.com como la URL base de WordPress. Reemplázala con la tuya.
Estructura de la API
WP Headless Mode usa la REST API nativa de WordPress (/wp-json/wp/v2/), endurecida con los headers y seguridad del plugin. Los endpoints más comunes para una landing:
| Endpoint | Descripción | Ejemplo |
|---|---|---|
GET /wp/v2/posts | Lista de posts paginada | ?per_page=6&_embed |
GET /wp/v2/posts/{id} | Post individual | /wp/v2/posts/1?_embed |
GET /wp/v2/pages | Lista de páginas | ?slug=home |
GET /wp/v2/categories | Categorías | ?per_page=100 |
GET /wp/v2/media/{id} | Imagen/archivo | retornada en _embedded |
GET /headless/v1/health | Health check del plugin | útil para verificar conexión |
_embed
El parámetro ?_embed incluye en la respuesta los datos relacionados (imagen destacada, autor, categorías) evitando múltiples requests. Muy útil para landing pages.
Autenticación
Si tienes activado Requerir auth en REST API, necesitas autenticarte. La forma más simple es con Application Passwords de WordPress:
Generar Application Password
En WordPress ve a Usuarios → Tu perfil, desplázate hasta Application Passwords, ingresa un nombre (ej: "Frontend") y haz click en Add New Application Password.
Guardar las credenciales
Copia el password generado (solo se muestra una vez). El formato para el header es usuario:app-password en Base64.
Usar en tu frontend
// En tu .env.local
WP_USER=admin
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
// Header resultante
const token = btoa(`${user}:${password}`);
// Authorization: Basic <token>
Next.js — Setup del proyecto
Usaremos Next.js 14+ con App Router y fetch nativo (no necesita axios ni swr para SSG).
Crear el proyecto
npx create-next-app@latest mi-landing --typescript --tailwind --app
cd mi-landing
Variables de entorno
Crea .env.local en la raíz:
NEXT_PUBLIC_WP_URL=https://tu-wp.com
WP_USER=admin
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
Next.js — API client
Crea lib/wordpress.ts:
const WP_URL = process.env.NEXT_PUBLIC_WP_URL;
const AUTH = process.env.WP_USER && process.env.WP_APP_PASSWORD
? `Basic ${btoa(`${process.env.WP_USER}:${process.env.WP_APP_PASSWORD}`)}`
: undefined;
async function wpFetch<T>(path: string): Promise<T> {
const res = await fetch(`${WP_URL}/wp-json${path}`, {
headers: {
'Content-Type': 'application/json',
...(AUTH ? { Authorization: AUTH } : {}),
},
next: { revalidate: 60 }, // ISR: revalidar cada 60s
});
if (!res.ok) throw new Error(`WP API error: ${res.status} ${path}`);
return res.json();
}
export interface WPPost {
id: number;
slug: string;
title: { rendered: string };
excerpt: { rendered: string };
date: string;
_embedded?: {
'wp:featuredmedia'?: [{ source_url: string; alt_text: string }];
author?: [{ name: string }];
};
}
export const getPosts = (perPage = 6) =>
wpFetch<WPPost[]>(`/wp/v2/posts?per_page=${perPage}&_embed`);
export const getPost = (slug: string) =>
wpFetch<WPPost[]>(`/wp/v2/posts?slug=${slug}&_embed`)
.then(posts => posts[0] ?? null);
export const getPage = (slug: string) =>
wpFetch<WPPost[]>(`/wp/v2/pages?slug=${slug}&_embed`)
.then(pages => pages[0] ?? null);
Next.js — Landing page
Edita app/page.tsx:
import { getPosts } from '@/lib/wordpress';
export default async function HomePage() {
const posts = await getPosts(6);
return (
<main className="max-w-4xl mx-auto px-6 py-16">
<h1 className="text-4xl font-bold mb-4">Blog</h1>
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{posts.map(post => {
const img = post._embedded?.['wp:featuredmedia']?.[0];
return (
<article key={post.id} className="border rounded-xl overflow-hidden">
{img && (
<img
src={img.source_url}
alt={img.alt_text}
className="w-full h-48 object-cover"
/>
)}
<div className="p-5">
<h2
className="text-lg font-semibold mb-2"
dangerouslySetInnerHTML={{ __html: post.title.rendered }}
/>
<p
className="text-sm text-gray-600"
dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }}
/>
<a
href={`/blog/${post.slug}`}
className="mt-3 inline-block text-sm font-medium text-pink-600"
>
Leer más →
</a>
</div>
</article>
);
})}
</div>
</main>
);
}
next: { revalidate: 60 } Next.js regenera la página en background cada 60 segundos (ISR). Para contenido estático puro usa revalidate: false y re-deploya al publicar.
Astro — Setup del proyecto
Astro es ideal para landing pages: genera HTML estático en build time, cero JS en el cliente salvo donde lo pidas explícitamente.
Crear el proyecto
npm create astro@latest mi-landing -- --template minimal
cd mi-landing
Variables de entorno
Crea .env en la raíz:
PUBLIC_WP_URL=https://tu-wp.com
WP_USER=admin
WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
Astro — API client
Crea src/lib/wordpress.ts:
const WP_URL = import.meta.env.PUBLIC_WP_URL;
const AUTH = import.meta.env.WP_USER
? `Basic ${btoa(`${import.meta.env.WP_USER}:${import.meta.env.WP_APP_PASSWORD}`)}`
: undefined;
export async function wpFetch<T>(path: string): Promise<T> {
const res = await fetch(`${WP_URL}/wp-json${path}`, {
headers: {
'Content-Type': 'application/json',
...(AUTH ? { Authorization: AUTH } : {}),
},
});
if (!res.ok) throw new Error(`${res.status} ${path}`);
return res.json();
}
export const getPosts = (n = 6) =>
wpFetch<any[]>(`/wp/v2/posts?per_page=${n}&_embed`);
Astro — Landing page
Edita src/pages/index.astro:
---
import { getPosts } from '../lib/wordpress';
const posts = await getPosts(6);
---
<html lang="es">
<head>
<meta charset="UTF-8" />
<title>Blog</title>
<style>
.grid { display: grid; gap: 2rem; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
article { border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; }
img { width: 100%; height: 200px; object-fit: cover; }
.body { padding: 1.25rem; }
h2 { font-size: 1.1rem; margin-bottom: .5rem; }
a { color: #ff176b; font-weight: 600; text-decoration: none; }
</style>
</head>
<body>
<main style="max-width:900px;margin:4rem auto;padding:0 1.5rem">
<h1>Blog</h1>
<div class="grid">
{posts.map(post => {
const img = post._embedded?.['wp:featuredmedia']?.[0];
return (
<article>
{img && <img src={img.source_url} alt={img.alt_text} />}
<div class="body">
<h2 set:html={post.title.rendered} />
<div set:html={post.excerpt.rendered} />
<a href={`/blog/${post.slug}`}>Leer más →</a>
</div>
</article>
);
})}
</div>
</main>
</body>
</html>
getPosts() en build time. El HTML resultante no contiene llamadas a la API — todo está embebido. Perfecto para landing pages con contenido que cambia poco.
Nuxt — Setup del proyecto
Nuxt 3 con el composable useFetch que maneja SSR, caché y estado automáticamente.
Crear el proyecto
npx nuxi@latest init mi-landing
cd mi-landing
Variables de entorno
Edita .env:
NUXT_PUBLIC_WP_URL=https://tu-wp.com
NUXT_WP_USER=admin
NUXT_WP_APP_PASSWORD=xxxx xxxx xxxx xxxx xxxx xxxx
Configurar nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
wpUser: process.env.NUXT_WP_USER,
wpAppPassword: process.env.NUXT_WP_APP_PASSWORD,
public: {
wpUrl: process.env.NUXT_PUBLIC_WP_URL,
},
},
});
Nuxt — API client
Crea composables/useWordPress.ts:
export function useWordPress() {
const config = useRuntimeConfig();
const base = config.public.wpUrl;
const auth = config.wpUser
? `Basic ${btoa(`${config.wpUser}:${config.wpAppPassword}`)}`
: undefined;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(auth ? { Authorization: auth } : {}),
};
const getPosts = (perPage = 6) =>
useFetch(`${base}/wp-json/wp/v2/posts`, {
query: { per_page: perPage, _embed: 1 },
headers,
key: 'posts',
default: () => [],
});
return { getPosts };
}
Nuxt — Landing page
Edita pages/index.vue:
<script setup lang="ts">
const { getPosts } = useWordPress();
const { data: posts } = await getPosts(6);
</script>
<template>
<main>
<h1>Blog</h1>
<div class="grid">
<article v-for="post in posts" :key="post.id">
<img
v-if="post._embedded?.['wp:featuredmedia']?.[0]"
:src="post._embedded['wp:featuredmedia'][0].source_url"
:alt="post._embedded['wp:featuredmedia'][0].alt_text"
/>
<div class="body">
<h2 v-html="post.title.rendered" />
<div v-html="post.excerpt.rendered" />
<NuxtLink :to="`/blog/${post.slug}`">Leer más →</NuxtLink>
</div>
</article>
</div>
</main>
</template>
<style scoped>
.grid { display: grid; gap: 2rem; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
article { border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; }
img { width: 100%; height: 200px; object-fit: cover; }
.body { padding: 1.25rem; }
a { color: #ff176b; font-weight: 600; text-decoration: none; }
</style>
Vanilla JS — Setup
Sin frameworks ni bundlers. Un archivo HTML + CSS + JS puro. Ideal para landing pages ligeras o prototipos rápidos.
Vanilla JS — API client
Crea js/api.js:
// js/api.js
const WP_URL = 'https://tu-wp.com';
async function wpFetch(path) {
const res = await fetch(`${WP_URL}/wp-json${path}`, {
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) throw new Error(`API Error: ${res.status}`);
return res.json();
}
export const getPosts = (n = 6) => wpFetch(`/wp/v2/posts?per_page=${n}&_embed`);
export const getSiteInfo = () => wpFetch(`/wp/v2/settings`); // requiere auth
export const getHealth = () => wpFetch(`/headless/v1/health`);
Vanilla JS — Landing page
Un solo archivo HTML autocontenido:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Blog</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #fafafa; color: #1a1a1a; }
main { max-width: 900px; margin: 4rem auto; padding: 0 1.5rem; }
h1 { font-size: 2rem; margin-bottom: 2rem; }
.grid { display: grid; gap: 1.5rem; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; }
.card img { width: 100%; height: 180px; object-fit: cover; }
.card-body { padding: 1.2rem; }
.card h2 { font-size: 1rem; margin-bottom: .5rem; }
.card p { font-size: .875rem; color: #6b7280; margin-bottom: 1rem; }
.card a { font-size: .875rem; font-weight: 600; color: #ff176b; text-decoration: none; }
.skeleton { background: #e5e7eb; border-radius: 8px; animation: pulse 1.5s infinite; }
@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .5 } }
</style>
</head>
<body>
<main>
<h1>Blog</h1>
<div id="grid" class="grid"></div>
</main>
<script type="module">
const WP_URL = 'https://tu-wp.com';
const grid = document.getElementById('grid');
// Render skeletons mientras carga
grid.innerHTML = Array(6).fill(`
<div class="card">
<div class="skeleton" style="height:180px"></div>
<div class="card-body">
<div class="skeleton" style="height:18px;margin-bottom:8px"></div>
<div class="skeleton" style="height:14px;width:70%"></div>
</div>
</div>`).join('');
try {
const res = await fetch(`${WP_URL}/wp-json/wp/v2/posts?per_page=6&_embed`);
const posts = await res.json();
grid.innerHTML = posts.map(post => {
const img = post._embedded?.['wp:featuredmedia']?.[0];
return `
<div class="card">
${img ? `<img src="${img.source_url}" alt="${img.alt_text}">` : ''}
<div class="card-body">
<h2>${post.title.rendered}</h2>
<p>${post.excerpt.rendered.replace(/<[^>]+>/g, '').slice(0, 120)}...</p>
<a href="/blog/${post.slug}">Leer más →</a>
</div>
</div>`;
}).join('');
} catch (err) {
grid.innerHTML = `<p style="color:red">Error cargando posts: ${err.message}</p>`;
}
</script>
</body>
</html>
Endpoints de referencia rápida
| Endpoint | Params útiles | Uso típico |
|---|---|---|
/wp/v2/posts | per_page, page, categories, _embed | Listado del blog |
/wp/v2/posts?slug=x | _embed | Post individual por slug |
/wp/v2/pages?slug=x | _embed | Páginas (home, about, etc.) |
/wp/v2/categories | per_page=100 | Menú de categorías |
/wp/v2/tags | per_page=100 | Nube de etiquetas |
/wp/v2/media/{id} | — | Metadatos de imagen |
/wp/v2/search | search=término | Buscador |
/headless/v1/health | — | Verificar conexión con WP |
Paginación
La REST API devuelve headers con la info de paginación:
X-WP-Total: 42 ← total de posts
X-WP-TotalPages: 7 ← total de páginas
// Leer headers en fetch:
const res = await fetch(`${WP_URL}/wp-json/wp/v2/posts?page=2`);
const total = res.headers.get('X-WP-Total');
const pages = res.headers.get('X-WP-TotalPages');
Troubleshooting
Error CORS en el browser
http://localhost:3000). Guarda y recarga.
Error 401 Unauthorized
Authorization: Basic <base64> desde el servidor.
Error 429 Too Many Requests
Los posts no muestran imagen destacada
Asegúrate de incluir _embed en el request. Las imágenes están en post._embedded['wp:featuredmedia'][0]. Si el post no tiene imagen asignada, ese array estará vacío — usa optional chaining (?.) para evitar errores.
HTML en title.rendered y excerpt.rendered
WordPress devuelve estos campos con HTML (entidades, etiquetas <p>, etc.). Para renderizarlos correctamente usa:
<h2 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
<h2 v-html="post.title.rendered" />
<h2 set:html={post.title.rendered} />
el.innerHTML = post.title.rendered;