Saltar al contenido
Volver a todos los artículos
Architecture Development WordPress 1 May 2026 · 14 min de lectura

WordPress headless: cómo construir una SPA con React que oculta WordPress completamente al usuario

Francisco Silva

Francisco Silva

Socio Sénior de Ingeniería WordPress.

WordPress headless: cómo construir una SPA con React que oculta WordPress completamente al usuario

Cómo utilizar WordPress como infraestructura backend para un SaaS moderno, con una SPA en React en una ruta pública personalizada, sin wp-admin, sin wp-login.php y con URLs limpias que recuerdan a Notion o Linear.

Cuando le dices a un desarrollador que estás construyendo un SaaS sobre WordPress, la reacción suele oscilar entre el escepticismo y la lástima. La imagen mental es /wp-admin/admin.php?page=my-tool, un formulario de wp-login.php con el logo de WordPress y una barra de administración en la parte superior de cada página que recuerda al usuario que está dentro del CMS de otra persona.

Esa imagen mental no es errónea — es la opción por defecto. Pero no es la única forma de usar WordPress.

Estoy desarrollando un SaaS para profesores de portugués (algo similar a Linear, pero para la gestión de calificaciones). El producto debe sentirse como una aplicación web moderna: URLs limpias, pantalla de inicio de sesión personalizada, sin ningún elemento visual de WordPress. Al mismo tiempo, quiero aprovechar lo que WordPress ofrece de forma gratuita: gestión de usuarios, autenticación, compatibilidad de alojamiento, infraestructura de API REST, el sistema cron y la gestión de despliegues.

Este artículo muestra cómo convertí WordPress en el motor sin convertirlo en la cara del producto.

Qué significa realmente «WordPress oculto»

El usuario abre app.example.com/app/ (o example.com/app/, según la estrategia de dominio) y ve una pantalla de inicio de sesión con la marca del producto — sin logos de WordPress, sin el formulario de wp-login. Tras iniciar sesión, accede a una SPA en /app/dashboard, /app/classes/7a, /app/students/123. Las URLs son enlazables. Los botones de retroceso y avance del navegador funcionan. La página nunca se recarga al navegar.

No hay barra de administración. No existe /wp-admin/. Si el usuario escribe accidentalmente wp-login.php, es redirigido al inicio de sesión propio de la aplicación. El único lugar donde WordPress es visible es la URL de las llamadas a la API REST en la pestaña Red — e incluso estas tienen su propio espacio de nombres (/wp-json/avaliar/v1/...).

Para lograrlo, es necesario que cuatro elementos funcionen conjuntamente:

  1. Una regla de reescritura personalizada que intercepte /app/* antes del enrutamiento por defecto de WordPress
  2. Una plantilla que renderice un único punto de montaje de React con la configuración inyectada
  3. Un espacio de nombres de API REST personalizado con autenticación mediante cookie y nonce
  4. Ocultar la barra de administración, bloquear wp-admin para usuarios sin privilegios de administrador y redirigir wp-login

Analicemos cada uno de ellos.

Reglas de reescritura personalizadas: interceptar antes de que WordPress tome el control

El enrutamiento de URLs de WordPress está gobernado por el sistema de reglas de reescritura. Por defecto, cualquier URL que no coincida con una entrada o página conocida cae en un error 404. El objetivo es enseñar a WordPress que /app/* es especial — es la SPA, no tocar.

declare(strict_types=1);

namespace AvaliarApp;

final class Router
{
    public const APP_PREFIX = '/app';

    public function register(): void
    {
        add_action('init', [$this, 'addRewriteRules']);
        add_filter('query_vars', [$this, 'addQueryVars']);
        add_action('template_redirect', [$this, 'maybeHandle']);
        add_filter('redirect_canonical', [$this, 'suppressCanonicalForApp'], 10, 2);
    }

    public function addRewriteRules(): void
    {
        add_rewrite_rule('^app/?$', 'index.php?avaliar_app=1', 'top');
        add_rewrite_rule('^app/(.+)$', 'index.php?avaliar_app=1', 'top');
    }

    public function addQueryVars(array $vars): array
    {
        $vars[] = 'avaliar_app';
        return $vars;
    }

    public function maybeHandle(): void
    {
        if (!$this->isAppRequest()) {
            return;
        }
        show_admin_bar(false);
        (new AppRenderer())->render();
        exit;
    }

    private function isAppRequest(): bool
    {
        if ((int) get_query_var('avaliar_app') === 1) {
            return true;
        }
        // Fallback: URL path check, in case rewrite rules aren't flushed
        $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
        return $path === self::APP_PREFIX
            || str_starts_with($path, self::APP_PREFIX . '/');
    }
}

Las dos reglas de reescritura son deliberadas. La primera coincide con /app y /app/. La segunda coincide con /app/cualquier/cosa. Ambas redirigen a index.php con la variable de consulta avaliar_app=1, que es la bandera para “esta es una petición de la SPA”.

La prioridad 'top' es fundamental. Sin ella, la regla predeterminada de WordPress /(.+)/?$ (que hace coincidir todo como un posible slug de entrada) podría capturar la URL primero.

El hook template_redirect se ejecuta después de que WordPress ha resuelto la petición pero antes de que se renderice cualquier plantilla. Si la bandera está activada, el código oculta la barra de administración, renderiza la SPA y ejecuta exit — evitando que WordPress haga cualquier otra cosa.

El problema con redirect_canonical

Hay una característica sutil de WordPress que rompió mi SPA la primera vez. Con los enlaces permanentes configurados como /%postname%/ (que es la configuración estándar en producción), WordPress ejecuta un hook redirect_canonical que añade barras diagonales finales a las URLs que no las tienen.

Parece inofensivo. Excepto que: si un usuario navega a /app/criteria?config=1 (sin barra diagonal antes de la cadena de consulta), WordPress detecta una ruta sin barra diagonal al final e intenta redirigir a /app/criteria/?config=1. En ocasiones, la redirección altera la cadena de consulta. En otras, provoca una recarga completa de la página que rompe el estado de la SPA. En cualquier caso, se producen fallos intermitentes.

La solución es un único filtro:

public function suppressCanonicalForApp(
    string|false $redirectUrl,
    string $requestedUrl
): string|false {
    $path = parse_url($requestedUrl, PHP_URL_PATH) ?: '/';
    if ($path === self::APP_PREFIX || str_starts_with($path, self::APP_PREFIX . '/')) {
        return false; // The SPA owns its URLs
    }
    return $redirectUrl;
}

Le indicamos a WordPress: para cualquier URL bajo /app/*, no intentes canonicalizar. La SPA es soberana en sus propias rutas.

La plantilla de renderizado: un único punto de montaje

Cuando /app/* coincide, el renderizador genera un documento HTML mínimo con un punto de montaje de React y la configuración que la SPA necesita para inicializarse:

// includes/App/AppRenderer.php
final class AppRenderer
{
    public function render(): void
    {
        nocache_headers();
        status_header(200);

        $config = $this->buildConfig();
        $viteAssets = (new ViteAssets())->entry('assets/src/app.tsx');

        require AVALIAR_PLUGIN_DIR . 'templates/public-app.php';
    }

    private function buildConfig(): array
    {
        $user = wp_get_current_user();
        $isAuth = $user->exists();

        return [
            'apiUrl' => rest_url('avaliar/v1/'),
            'nonce' => $isAuth ? wp_create_nonce('wp_rest') : null,
            'appBaseUrl' => Router::APP_PREFIX,
            'env' => defined('AVALIAR_ENV') ? AVALIAR_ENV : 'production',
            'version' => AVALIAR_VERSION,
            'isAuthenticated' => $isAuth,
            'user' => $isAuth ? [
                'id' => $user->ID,
                'login' => $user->user_login,
                'displayName' => $user->display_name,
            ] : null,
        ];
    }
}

La plantilla en sí es pequeña:

<!-- templates/public-app.php -->
<!DOCTYPE html>
<html lang="<?= esc_attr(get_locale()) ?>">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?= esc_html(get_bloginfo('name')) ?></title>
    <script>
        window.__AVALIAR__ = <?= wp_json_encode($config) ?>;
    </script>
    <?php foreach ($viteAssets['css'] as $href): ?>
        <link rel="stylesheet" href="<?= esc_url($href) ?>">
    <?php endforeach; ?>
</head>
<body>
    <div id="root"></div>
    <?php foreach ($viteAssets['js'] as $src): ?>
        <script type="module" src="<?= esc_url($src) ?>"></script>
    <?php endforeach; ?>
</body>
</html>

La SPA lee window.__AVALIAR__ al arrancar, determina si el usuario está autenticado y conoce la URL base para las llamadas a la API. A partir de ahí, React Router toma el control: el usuario navega en el lado del cliente y solo contacta con el servidor cuando la SPA obtiene datos mediante REST.

Vite + WordPress: la gestión de assets en desarrollo y producción

Aquí existen dos retos:

En desarrollo, el código React reside en assets/src/app.tsx y es servido por el servidor de desarrollo de Vite en el puerto 5173 (con HMR). WordPress sirve el envoltorio HTML, pero necesita saber dónde encontrar los assets de desarrollo.

En producción, Vite genera bundles optimizados en assets/build/, y WordPress necesita leer el manifiesto (assets/build/.vite/manifest.json) para saber qué archivos JS/CSS con hash debe cargar.

El helper ViteAssets gestiona ambos casos:

final class ViteAssets
{
    public function entry(string $entryPath): array
    {
        // In dev: a `hot` file written by Vite signals the dev server is running
        $hotFile = AVALIAR_PLUGIN_DIR . 'assets/build/hot';
        if (file_exists($hotFile)) {
            $devUrl = trim(file_get_contents($hotFile));
            return [
                'js' => [
                    "{$devUrl}/@vite/client",
                    "{$devUrl}/{$entryPath}",
                ],
                'css' => [],
            ];
        }

        // In prod: read the manifest, return hashed file URLs
        $manifestPath = AVALIAR_PLUGIN_DIR . 'assets/build/.vite/manifest.json';
        $manifest = json_decode(file_get_contents($manifestPath), true);
        $entry = $manifest[$entryPath] ?? null;

        if (!$entry) {
            return ['js' => [], 'css' => []];
        }

        $buildUrl = AVALIAR_PLUGIN_URL . 'assets/build/';
        return [
            'js' => [$buildUrl . $entry['file']],
            'css' => array_map(
                fn($cssFile) => $buildUrl . $cssFile,
                $entry['css'] ?? []
            ),
        ];
    }
}

La configuración de Docker para desarrollo: WordPress se ejecuta en un contenedor en el puerto 8080. Vite se ejecuta en el host en el puerto 5173 con npm run dev. Ambos utilizan un volumen compartido para que Vite pueda escribir el archivo hot que WordPress lee.

API REST con un espacio de nombres personalizado

La SPA llama a /wp-json/avaliar/v1/classes, no a /wp-json/wp/v2/posts. El espacio de nombres personalizado separa los endpoints de la aplicación de cualquier endpoint del núcleo de WordPress y permite que el plugin defina sus propias reglas de permisos.

Un controlador tiene este aspecto:

namespace AvaliarApi;

final class ClassesController
{
    public function register(): void
    {
        add_action('rest_api_init', [$this, 'registerRoutes']);
    }

    public function registerRoutes(): void
    {
        register_rest_route('avaliar/v1', '/classes', [
            'methods' => 'GET',
            'callback' => [$this, 'index'],
            'permission_callback' => [Support::class, 'requireAuthenticatedTeacher'],
        ]);

        register_rest_route('avaliar/v1', '/classes', [
            'methods' => 'POST',
            'callback' => [$this, 'create'],
            'permission_callback' => [Support::class, 'requireAuthenticatedTeacher'],
            'args' => [
                'name' => ['required' => true, 'type' => 'string'],
                'subject_id' => ['required' => true, 'type' => 'integer'],
            ],
        ]);
    }

    public function index(WP_REST_Request $request): WP_REST_Response
    {
        $userId = get_current_user_id();
        $repository = new WpdbClassroomRepository();
        $classes = $repository->findAllByTeacher($userId);

        return rest_ensure_response([
            'data' => array_map(fn($c) => $c->toArray(), $classes),
        ]);
    }
}

El permission_callback es donde reside la verificación de autenticación. El mío tiene este aspecto:

public static function requireAuthenticatedTeacher(WP_REST_Request $request): bool|WP_Error
{
    if (!is_user_logged_in()) {
        return new WP_Error('rest_forbidden', 'Authentication required', ['status' => 401]);
    }

    $user = wp_get_current_user();
    if (!in_array('avaliar_teacher', $user->roles, true)
        && !in_array('administrator', $user->roles, true)) {
        return new WP_Error('rest_forbidden', 'Insufficient permissions', ['status' => 403]);
    }

    return true;
}

WordPress gestiona automáticamente la verificación de cookie y nonce cuando la SPA envía X-WP-Nonce: <nonce> con cada solicitud. El nonce proviene de la configuración de arranque inyectada en el momento del renderizado.

Bloquear wp-admin y wp-login para usuarios no administradores

El usuario nunca debería ver wp-admin. Si no es un administrador de WordPress, cualquier intento de acceder a /wp-admin/* redirige a /app/. Cualquier intento de acceder a /wp-login.php (salvo para el restablecimiento de contraseña, que utiliza un flujo personalizado en nuestra SPA) redirige a /app/login:

namespace AvaliarCore;

final class AdminAccess
{
    public function register(): void
    {
        add_action('admin_init', [$this, 'redirectNonAdmins']);
        add_action('login_init', [$this, 'redirectAwayFromLogin']);
    }

    public function redirectNonAdmins(): void
    {
        // Allow AJAX (admin-ajax.php is fine; we're only blocking the UI)
        if (wp_doing_ajax()) return;

        $user = wp_get_current_user();
        $isAdmin = $user->exists() && in_array('administrator', $user->roles, true);

        if (!$isAdmin) {
            wp_safe_redirect(home_url('/app/'));
            exit;
        }
    }

    public function redirectAwayFromLogin(): void
    {
        // Allow specific actions: password reset link, logout
        $action = $_REQUEST['action'] ?? '';
        $allowedActions = ['logout', 'rp', 'resetpass'];

        if (in_array($action, $allowedActions, true)) {
            return;
        }

        // Everything else: send them to the SPA's login
        wp_safe_redirect(home_url('/app/login'));
        exit;
    }
}

El último detalle: ocultar la barra de administración incluso para los administradores cuando se encuentran dentro de la SPA:

// Inside Router::maybeHandle()
show_admin_bar(false);

Combinado con exit tras renderizar la plantilla, WordPress nunca tiene la oportunidad de inyectar el marcado de su barra de administración.

El resultado, en URLs

Antes:

  • /wp-admin/admin.php?page=my-tool — WordPress visible
  • /wp-login.php — WordPress visible
  • Barra de administración de WordPress en todas las páginas
  • URLs con formato ?p=123

Después:

  • /app/ — inicio de sesión o panel con marca propia
  • /app/classes/7a — ruta SPA limpia
  • /app/students/123 — enlazable y compartible
  • Sin barra de administración ni interfaz de wp-admin
  • El usuario no tiene forma de saber que existe WordPress a menos que abra las DevTools de red

Cuándo este enfoque es adecuado (y cuándo no)

Adecuado cuando:

  • Quieres la infraestructura de usuarios/autenticación/hosting de WordPress pero no quieres que los usuarios vean el cromo de WP
  • El producto es suficientemente interactivo como para justificar una SPA (formularios, paneles de control, actualizaciones en tiempo real)
  • Tú o tu equipo os sentís cómodos tanto con PHP como con un pipeline de compilación JS
  • Podéis comprometeros a mantener el código base de WordPress actualizado por seguridad

Incorrecto cuando:

  • El producto tiene mucho contenido (blogs, sitios de marketing) — deja que WordPress haga lo que se le da bien
  • El equipo trabaja solo con JS y encuentra PHP una barrera — elige un backend diferente
  • Necesitas un rendimiento extremo en el edge — WordPress + PHP + MySQL tiene una sobrecarga considerable
  • Estás en un hosting compartido que no admite reglas de reescritura personalizadas ni PHP moderno

Lo que aprendí

WordPress es más flexible de lo que su reputación sugiere. Con reglas de reescritura, espacios de nombres REST personalizados y hooks bien disciplinados, puedes construir un producto que no se parezca en nada a un sitio WordPress típico. El discurso de la comunidad se centra en temas y plugins, pero la plataforma central es mucho más capaz de lo que parece.

El comportamiento predeterminado te complica las cosas pequeñas. La inyección de la barra de administración, la canonicalización de redirecciones, el aspecto del formulario de inicio de sesión — cada uno de estos elementos debe desactivarse o reemplazarse explícitamente. Documenta cada problema cuando lo descubras; tu yo del futuro no recordará por qué escribiste ese filtro.

Vite + WordPress es una combinación muy productiva. La experiencia de desarrollo (HMR, compilaciones rápidas) es lo que esperan los equipos de frontend modernos. La salida en producción (basada en manifesto, assets con hash, empaquetado óptimo) es lo que necesita el entorno productivo. El pegamento PHP entre ambos es pequeño y prácticamente se escribe solo una vez.

El espacio de nombres REST es más importante de lo que la gente reconoce. /wp-json/avaliar/v1/... es una señal clara — para otros desarrolladores, para depuradores, para generadores de documentación de API — de que se trata de funcionalidad a nivel de aplicación, no del núcleo de WordPress. No cedas a la tentación de registrar tus endpoints bajo wp/v2/.

Si estás considerando este enfoque para tu propio producto — construir un SaaS con WordPress como motor pero no como cara visible — puedo ayudarte a pensar en la arquitectura. El patrón es el mismo en todos los productos; las partes complicadas están en los detalles específicos de tu modelo de autenticación, la forma de tus datos y las prioridades de experiencia de usuario.

#headless-wordpress #react #saas #typescript #vite #wordpress

Compartir artículo

Últimos Insights