En una arquitectura Headless WordPress, un obstáculo esencial para el equipo de redacción es el aislamiento de borradores: el frontend en Next.js atiende a los visitantes desde una caché estática inmutable (ISR/SSG) alojada en Edge CDNs, mientras que los contenidos no publicados (borradores, entradas programadas y revisiones) residen bloqueados en la base de datos de WordPress sin acceso público anónimo.
Para resolver este desafío en el stack moderno de Next.js 16 (App Router), el framework ofrece la funcionalidad nativa Draft Mode (que sustituye al mecanismo en desuso previewData de Pages Router).
Mecánica operativa de Draft Mode:
preview_post_link de WordPress intercepta el enlace predeterminado y redirige el navegador del redactor a un endpoint seguro del frontend: /api/draft?secret=...&id=42&slug=mi-borrador.draftMode().enable().__prerender_bypass con directivas SameSite=Lax, Secure y HttpOnly.__prerender_bypass, el servidor de Next.js desactiva la caché estática (Data Cache y Full Route Cache) de manera exclusiva para la sesión del redactor, operando en modo de renderizado de servidor bajo demanda.const { isEnabled } = await draftMode();. Al estar activo, el componente utiliza credenciales de máquina seguras (Application Password o JWT de alcance acotado) para solicitar la revisión más reciente del borrador desde los endpoints protegidos de la API de WordPress (/wp/v2/posts/{id}/revisions).Para cumplir con estándares de seguridad corporativos, el flujo de previsualización debe neutralizar ataques de Open Redirect (redirecciones maliciosas mediante manipulación del parámetro slug), validar marcas de tiempo en tokens y proporcionar una vía clara de salida (/api/disable-draft).
Protocolo integral de enlace seguro y renderizado de revisiones de borrador:
+-----------------------------------------------------------------------------------------+
| PANEL DE WORDPRESS (Gutenberg) |
+-----------------------------------------------------------------------------------------+
| [El editor pulsa "Vista previa"] |
| | |
| v |
| [Filtro: preview_post_link] ---> Genera enlace seguro con token temporal: |
| https://frontend.com/api/draft?secret=TOKEN&id=42... |
+-----------------------------------------------------------------------------------------+
|
| 1. Navegación del navegador (GET)
v
+-----------------------------------------------------------------------------------------+
| NEXT.JS 16 ROUTE HANDLER |
+-----------------------------------------------------------------------------------------+
| [/api/draft/route.ts] |
| | |
| +---> [Verificación de secreto: crypto.timingSafeEqual()] |
| | |
| +---> [draftMode().enable()] ---> Establece cookie: __prerender_bypass |
| | |
| +---> [Redirección temporal 307 a /blog/p1] |
+-----------------------------------------------------------------------------------------+
|
| 2. Redirección con cookie __prerender_bypass
v
+-----------------------------------------------------------------------------------------+
| NEXT.JS 16 REACT SERVER COMPONENT (RSC) |
+-----------------------------------------------------------------------------------------+
| [app/blog/[slug]/page.tsx] |
| | |
| +---> const { isEnabled } = await draftMode(); // ¡TRUE! |
| | | |
| | +---> [BYPASS CACHÉ CDN]: Consulta de borrador con token M2M |
| | |
| +---> Renderizado de contenido privado + Banner "Modo Previsualización [Salir]" |
+-----------------------------------------------------------------------------------------+API integrada de Next.js para suspender temporalmente la caché estática para sesiones autenticadas sin reconstruir la aplicación.
Cookie HTTP firmada criptográficamente por Next.js al activar Draft Mode que indica al router perimetral la necesidad de renderizar dinámicamente.
Mecanismo de obtención del borrador o autoguardado más reciente desde la tabla wp_posts de la base de datos utilizando credenciales administrativas seguras.
Saneamiento estricto de las rutas de redirección, imposibilitando el desvío de usuarios hacia dominios externos fraudulentos mediante los parámetros slug o redirect.
Cuenta de servicio técnico con privilegios mínimos de lectura privada (read_private_posts) utilizada exclusivamente por el servidor Next.js para consultar la API de WordPress.
WordPress intercepta la acción mediante el filtro preview_post_link y genera una URL firmada con un token criptográfico temporal.
El endpoint app/api/draft/route.ts valida el secreto en tiempo constante y comprueba la existencia del registro en la API de WordPress.
Next.js ejecuta draftMode().enable() e inyecta la cookie __prerender_bypass mediante Set-Cookie, ejecutando un redirect 307 a la ruta saneada.
El Server Component detecta draftMode().isEnabled = true y recupera la revisión privada a través de una Application Password de M2M.
La página se dibuja con una barra persistente 'Modo Previsualización Activo' enlazada a /api/disable-draft para restaurar la navegación estándar.
Si el handler /api/draft confía ciegamente en parámetros de consulta no depurados:
// VULNERABILIDAD CRÍTICA:
redirect(searchParams.get('slug'));Un atacante podría elaborar URLs como https://frontend.com/api/draft?secret=VALID&slug=//atacante.com/login, convirtiendo el dominio oficial en una pasarela de redirección abierta para phishing.
Regla de saneamiento corporativo:
La ruta de redirección debe ser estrictamente relativa, comenzando por una sola barra / y excluyendo dobles barras iniciales // o dos puntos indicativos de esquema:
function sanitizeRedirectPath(path: string | null): string {
if (!path || !path.startsWith('/') || path.startsWith('//') || path.includes(':')) {
return '/';
}
return path;
}A continuación se detalla la solución completa: filtro en PHP 8.4 para WordPress encargado de generar enlaces firmados y Route Handlers seguros de Next.js 16 para activar y desactivar Draft Mode.
// ============================================================================
// PARTE 1: Next.js 16 Route Handler de activación (app/api/draft/route.ts)
// ============================================================================
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';
/**
* Saneamiento de rutas de redirección relativas para evitar Open Redirect.
*/
function sanitizeRedirect(path: string | null): string {
if (!path || !path.startsWith('/') || path.startsWith('//') || path.includes(':')) {
return '/';
}
return path;
}
export async function GET(request: NextRequest): Promise<NextResponse | void> {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
const id = searchParams.get('id');
const configuredSecret = process.env.WORDPRESS_PREVIEW_SECRET;
if (!configuredSecret || !secret) {
return NextResponse.json({ message: 'Token de previsualización inválido' }, { status: 401 });
}
// Comparación segura del secreto frente a Timing Attacks
const secretBuffer = Buffer.from(secret, 'utf8');
const expectedBuffer = Buffer.from(configuredSecret, 'utf8');
if (
secretBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(secretBuffer, expectedBuffer)
) {
return NextResponse.json({ message: 'Acceso no autorizado a previsualización' }, { status: 401 });
}
// 1. Activa Draft Mode (establece la cookie __prerender_bypass)
const draft = await draftMode();
draft.enable();
// 2. Redirección segura hacia la ruta destino
const redirectPath = slug ? `/blog/${slug}` : (id ? `/blog/?p=${id}` : '/');
redirect(sanitizeRedirect(redirectPath));
}
// ============================================================================
// PARTE 2: Next.js 16 Route Handler de desconexión (app/api/disable-draft/route.ts)
// ============================================================================
export async function GET(request: NextRequest): Promise<NextResponse | void> {
const { searchParams } = new URL(request.url);
const redirectUrl = sanitizeRedirect(searchParams.get('redirect'));
// Desactiva Draft Mode (elimina la cookie __prerender_bypass)
const draft = await draftMode();
draft.disable();
redirect(redirectUrl);
}
// ============================================================================
// PARTE 3: Componente de servidor para banner de estado (components/PreviewBanner.tsx)
// ============================================================================
export async function PreviewBanner({ currentPath }: { currentPath: string }) {
const { isEnabled } = await draftMode();
if (!isEnabled) {
return null;
}
return (
<aside className="sticky top-0 z-50 flex items-center justify-between bg-accent-amber/90 px-4 py-2 text-xs font-semibold text-black backdrop-blur-md shadow-md">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-red-600 animate-pulse" />
<span>Atención: Modo Previsualización Activo (mostrando borradores y revisiones)</span>
</div>
<a
href={`/api/disable-draft?redirect=${encodeURIComponent(currentPath)}`}
className="rounded bg-black px-3 py-1 text-white hover:bg-neutral-800 transition-colors"
>
Salir de previsualización
</a>
</aside>
);
}
// ============================================================================
// PARTE 4: Filtro de previsualización en WordPress (wp-content/mu-plugins/agentic-preview.php)
// ============================================================================
/*
<?php
declare(strict_types=1);
namespace AgenticLab\Integration;
final class AgenticPreviewLinkFilter {
private const string FRONTEND_DRAFT_URL = 'https://frontend.agenticlab.com/api/draft';
public static function init(): void {
add_filter('preview_post_link', [self::class, 'filter_preview_link'], 10, 2);
}
public static function filter_preview_link(string $link, \WP_Post $post): string {
$secret = defined('WORDPRESS_PREVIEW_SECRET') ? WORDPRESS_PREVIEW_SECRET : '';
if (empty($secret)) {
return $link;
}
return add_query_arg([
'secret' => $secret,
'id' => $post->ID,
'slug' => $post->post_name ?: 'draft-' . $post->ID,
], self::FRONTEND_DRAFT_URL);
}
}
add_action('init', [AgenticPreviewLinkFilter::class, 'init']);
*/
Generación de una clave de 32 bytes con random_bytes(32) y registro en WORDPRESS_PREVIEW_SECRET tanto en el frontend como en el CMS.
Establece un canal mutuo de confianza criptográfica.
Creación de app/api/draft/route.ts y app/api/disable-draft/route.ts con validación anti-Open Redirect e invocación de draftMode().
Gobierna el ciclo de vida de la cookie firmada __prerender_bypass.
Enrutamiento del botón 'Vista previa' de Gutenberg hacia el endpoint del frontend transmitiendo ID de publicación y token temporal.
Ofrece una experiencia de usuario natural e integrada a los redactores.
Verificación de draftMode().isEnabled en componentes de servidor, obteniendo revisiones privadas e insertando el banner de salida.
Garantiza una representación fiel de los cambios y un retorno sencillo a la navegación pública.
La orquestación de un sistema de previsualización sólido en arquitecturas Headless WordPress se apoya en tres principios:
draftMode().enable() en el App Router gestionado a través de la cookie segura __prerender_bypass.