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

Antes de empezar Asegúrate de tener WP Headless Mode instalado y configurado en tu WordPress. El modo headless debe estar activo y los orígenes CORS configurados con la URL de tu frontend.

Lista de verificación:

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:

EndpointDescripciónEjemplo
GET /wp/v2/postsLista de posts paginada?per_page=6&_embed
GET /wp/v2/posts/{id}Post individual/wp/v2/posts/1?_embed
GET /wp/v2/pagesLista de páginas?slug=home
GET /wp/v2/categoriesCategorías?per_page=100
GET /wp/v2/media/{id}Imagen/archivoretornada en _embedded
GET /headless/v1/healthHealth check del pluginútil para verificar conexión
Tip: usa _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>
Nunca expongas las credenciales en el cliente Las llamadas autenticadas deben hacerse siempre desde el servidor (SSR/SSG), nunca desde el browser. En Next.js/Astro/Nuxt usa variables de entorno de servidor.

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>
  );
}
Static Generation vs ISR Con 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>
Build estático Astro ejecuta 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.

Nota sobre autenticación En Vanilla JS el código corre en el browser. Si tu API es pública (CORS abierto, sin auth requerida) funciona directo. Si requieres auth, necesitas un proxy de servidor o una función serverless para no exponer credenciales.

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

EndpointParams útilesUso típico
/wp/v2/postsper_page, page, categories, _embedListado del blog
/wp/v2/posts?slug=x_embedPost individual por slug
/wp/v2/pages?slug=x_embedPáginas (home, about, etc.)
/wp/v2/categoriesper_page=100Menú de categorías
/wp/v2/tagsper_page=100Nube de etiquetas
/wp/v2/media/{id}Metadatos de imagen
/wp/v2/searchsearch=términoBuscador
/headless/v1/healthVerificar 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

Access to fetch blocked by CORS policy Ve a WP Headless Mode → Configuración → Orígenes CORS permitidos y agrega la URL de tu frontend (incluyendo el puerto: http://localhost:3000). Guarda y recarga.

Error 401 Unauthorized

rest_forbidden Tienes activado "Requerir auth en REST API". Genera un Application Password en WordPress y pásalo en el header Authorization: Basic <base64> desde el servidor.

Error 429 Too Many Requests

rate_limit_exceeded Has superado el límite de requests por hora. Aumenta el valor en Configuración → Requests por hora, o autentica tus requests (obtienen 5× el límite).

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;