Saltar para o conteúdo
Voltar a todos os artigos
Architecture Development WordPress 1 May 2026 · 13 min de leitura

WordPress Headless: construir uma SPA em React que esconde completamente o WordPress ao utilizador

Francisco Silva

Francisco Silva

Parceiro Sénior de Engenharia WordPress.

WordPress Headless: construir uma SPA em React que esconde completamente o WordPress ao utilizador

Como usar o WordPress como infraestrutura de backend para um SaaS moderno, com uma SPA em React numa rota pública personalizada, sem wp-admin, sem wp-login.php, e com URLs limpas semelhantes às do Notion ou do Linear.

Quando se diz a um programador que se está a construir um SaaS em WordPress, a reação situa-se normalmente entre o ceticismo e a comiseração. A imagem mental é /wp-admin/admin.php?page=my-tool, um formulário wp-login.php com o logótipo do WordPress, e uma barra de administração no topo de cada página a lembrar ao utilizador que está dentro do CMS de outra pessoa.

Essa imagem mental não está errada — é a predefinição. Mas não é a única forma de usar o WordPress.

Estou a construir um SaaS para professores portugueses (imagine o Linear, mas para gestão de notas). O produto precisa de parecer uma aplicação web moderna: URLs limpas, ecrã de login personalizado, sem qualquer elemento visual do WordPress. Ao mesmo tempo, quero o que o WordPress me oferece gratuitamente: gestão de utilizadores, autenticação, compatibilidade de alojamento, infraestrutura de API REST, o sistema de cron e a história de deployment.

Este artigo mostra como tornei o WordPress o motor sem o tornar a face do produto.

O que significa realmente «WordPress oculto»

O utilizador abre app.example.com/app/ (ou example.com/app/, consoante a estratégia de domínio) e vê um ecrã de login com a identidade visual do produto — sem logótipos do WordPress, sem o formulário wp-login. Após o login, está numa SPA em /app/dashboard, /app/classes/7a, /app/students/123. Os URLs são partilháveis. Os botões de retroceder/avançar do browser funcionam. A página nunca recarrega durante a navegação.

Não existe barra de administração. Não existe /wp-admin/. Se o utilizador escrever acidentalmente wp-login.php, é redirecionado para o login da própria aplicação. O único lugar onde o WordPress é visível é no URL das chamadas à API REST no separador Network — e mesmo essas têm namespace próprio (/wp-json/avaliar/v1/...).

Para chegar lá, quatro coisas precisam de funcionar em conjunto:

  1. Uma regra de reescrita personalizada que interceta /app/* antes do roteamento padrão do WordPress
  2. Um template que renderiza um único ponto de montagem React com configuração injetada
  3. Um namespace de API REST personalizado com autenticação por cookie + nonce
  4. Ocultar a barra de administração, bloquear o wp-admin para utilizadores não administradores e redirecionar o wp-login

Deixe-me explicar cada um deles.

Regras de reescrita personalizadas: intercetar antes de o WordPress assumir o controlo

O encaminhamento de URLs do WordPress é gerido pelo sistema de regras de reescrita. Por defeito, qualquer URL que não corresponda a um post ou página conhecido resulta num erro 404. O objetivo é ensinar o WordPress que /app/* é especial — trata-se da SPA, mãos fora.

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 . '/');
    }
}

As duas regras de reescrita são intencionais. A primeira corresponde a /app e /app/. A segunda corresponde a /app/qualquer/coisa. Ambas reencaminham para index.php com a variável de consulta avaliar_app=1, que é o sinalizador para “este é um pedido de SPA”.

A prioridade 'top' é fundamental. Sem ela, a regra predefinida do WordPress /(.+)/?$ (que corresponde a tudo como um possível slug de post) pode capturar o URL em primeiro lugar.

O hook template_redirect é acionado após o WordPress ter resolvido o pedido, mas antes de qualquer template ser renderizado. Se o sinalizador estiver definido, o código oculta a barra de administração, renderiza a SPA e executa exit — impedindo o WordPress de fazer qualquer outra coisa.

O problema do redirect_canonical

Existe uma funcionalidade subtil do WordPress que quebrou a minha SPA da primeira vez. Com os permalinks definidos para /%postname%/ (que é a configuração padrão em produção), o WordPress executa um hook redirect_canonical que adiciona barras finais aos URLs que não as possuem.

Parece inofensivo. Exceto que: se um utilizador navegar para /app/criteria?config=1 (sem barra final antes da query string), o WordPress vê um caminho sem a barra final e tenta redirecionar para /app/criteria/?config=1. Por vezes o redirecionamento corrompe a query string. Por vezes faz um recarregamento completo da página que quebra o estado da SPA. De qualquer forma, falhas intermitentes.

A correção resume-se a um ú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;
}

Dizemos ao WordPress: para qualquer URL sob /app/*, não tentar canonicalizar. A SPA é soberana nas suas próprias rotas.

O template de renderização: um único ponto de montagem

Quando /app/* corresponde, o renderizador produz um documento HTML mínimo com um ponto de montagem React e a configuração de que a SPA necessita para arrancar:

// 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,
        ];
    }
}

O próprio template é simples:

<!-- 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>

A SPA lê window.__AVALIAR__ no arranque, sabe se o utilizador está autenticado e conhece o URL base para as chamadas à API. A partir daí, o React Router assume o controlo — o utilizador navega do lado do cliente, contactando o servidor apenas quando a SPA obtém dados via REST.

Vite + WordPress: a gestão de assets em desenvolvimento e produção

Dois desafios a considerar:

Em desenvolvimento, o código React reside em assets/src/app.tsx e é servido pelo servidor de desenvolvimento do Vite na porta 5173 (com HMR). O WordPress serve o wrapper HTML mas precisa de saber onde encontrar os assets de desenvolvimento.

Em produção, o Vite compila bundles otimizados para assets/build/, e o WordPress precisa de ler o manifesto (assets/build/.vite/manifest.json) para saber quais os ficheiros JS/CSS com hash a carregar.

O helper ViteAssets trata de ambos os 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'] ?? []
            ),
        ];
    }
}

A configuração do Docker para desenvolvimento: o WordPress corre num contentor na porta 8080. O Vite corre no host na porta 5173 com npm run dev. Ambos utilizam um volume partilhado para que o Vite possa escrever o ficheiro hot que o WordPress lê.

API REST com um namespace personalizado

A SPA chama /wp-json/avaliar/v1/classes, não /wp-json/wp/v2/posts. O namespace personalizado separa os endpoints da aplicação de quaisquer endpoints do núcleo do WordPress e permite que o plugin defina as suas próprias regras de permissão.

Um controlador tem o seguinte aspeto:

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),
        ]);
    }
}

O permission_callback é onde reside a verificação de autenticação. O meu tem o seguinte aspeto:

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;
}

O WordPress trata automaticamente da verificação do cookie e do nonce quando a SPA envia X-WP-Nonce: <nonce> em cada pedido. O nonce foi obtido a partir da configuração de inicialização injetada no momento da renderização.

Bloquear o wp-admin e o wp-login para não administradores

O utilizador nunca deverá ver o wp-admin. Se não for um administrador do WordPress, qualquer tentativa de aceder a /wp-admin/* redireciona para /app/. Qualquer tentativa de aceder a /wp-login.php (exceto para redefinição de palavra-passe, que utiliza um fluxo personalizado na nossa SPA) redireciona para /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;
    }
}

O elemento final: ocultar a barra de administração mesmo para administradores, quando estão dentro da SPA:

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

Combinado com exit após renderizar o template, o WordPress nunca tem oportunidade de injetar o markup da barra de administração.

O resultado, em URLs

Antes:

  • /wp-admin/admin.php?page=my-tool — WordPress visível
  • /wp-login.php — WordPress visível
  • Barra de administração do WordPress em todas as páginas
  • URLs no formato ?p=123

Depois:

  • /app/ — login ou dashboard com marca própria
  • /app/classes/7a — rota SPA limpa
  • /app/students/123 — ligável e partilhável
  • Sem barra de administração, sem o chrome do wp-admin
  • O utilizador não tem qualquer indicação de que o WordPress existe, a menos que abra as DevTools de Rede

Quando esta abordagem é adequada (e quando não é)

Adequada quando:

  • Quer a infraestrutura de utilizadores/autenticação/alojamento do WordPress mas não quer que os utilizadores vejam o chrome do WP
  • O produto é suficientemente interativo para justificar uma SPA (formulários, dashboards, atualizações em tempo real)
  • Você ou a sua equipa estão confortáveis tanto com PHP como com um pipeline de build JS
  • Consegue comprometer-se a manter a base de código WordPress atualizada por razões de segurança

Errado quando:

  • O produto é maioritariamente conteúdo (blogs, sites de marketing) — deixe o WordPress fazer o que faz bem
  • A equipa é exclusivamente JS e considera o PHP uma barreira — escolha um backend diferente
  • Precisa de performance extrema na edge — WordPress + PHP + MySQL tem overhead
  • Está em alojamento partilhado que não suporta regras de reescrita personalizadas ou PHP moderno

O que aprendi

O WordPress é mais flexível do que a sua reputação sugere. Com regras de reescrita, namespaces REST personalizados e hooks bem disciplinados, é possível construir um produto que não se parece em nada com um site WordPress típico. O discurso da comunidade centra-se em temas e plugins, mas a plataforma central é muito mais capaz do que isso.

O comportamento predefinido dificulta as pequenas coisas. A injeção da barra de administração, a canonicalização de redireccionamentos, o tema do formulário de login — cada um destes aspetos tem de ser explicitamente desativado ou substituído. Documente cada armadilha quando a descobrir; o seu eu futuro não se vai lembrar por que razão escreveu aquele filtro.

Vite + WordPress é uma combinação produtiva. A experiência de desenvolvimento (HMR, rebuilds rápidos) é o que as equipas de frontend modernas esperam. O output de produção (baseado em manifesto, assets com hash, bundling otimizado) é o que a produção necessita. O código PHP de ligação entre ambos é pequeno e praticamente se escreve sozinho.

O namespace REST é mais importante do que as pessoas reconhecem. /wp-json/avaliar/v1/... é um sinal claro — para outros programadores, para quem faz debug, para geradores de documentação de API — de que se trata de funcionalidade ao nível da aplicação, e não do core do WordPress. Não ceda à tentação de registar os seus endpoints em wp/v2/.

Se está a considerar esta abordagem para o seu próprio produto — construir um SaaS com o WordPress como motor, mas não como rosto — posso ajudá-lo a pensar na arquitetura. O padrão é o mesmo em diferentes produtos; as partes complicadas estão nos detalhes específicos do seu modelo de autenticação, da estrutura dos seus dados e das prioridades da experiência do utilizador.

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

Partilhar artigo

Últimos Insights