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:
- Uma regra de reescrita personalizada que interceta
/app/*antes do roteamento padrão do WordPress - Um template que renderiza um único ponto de montagem React com configuração injetada
- Um namespace de API REST personalizado com autenticação por cookie + nonce
- 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.