Comment utiliser WordPress comme infrastructure back-end pour un SaaS moderne, avec un SPA React sur une route publique personnalisée, sans wp-admin, sans wp-login.php, et des URL propres qui ressemblent à Notion ou Linear.
Quand on annonce à un développeur que l’on construit un SaaS sur WordPress, la réaction oscille généralement entre le scepticisme et la pitié. L’image mentale qui s’impose est celle d’une URL /wp-admin/admin.php?page=my-tool, d’un formulaire wp-login.php arborant le logo WordPress, et d’une barre d’administration en haut de chaque page rappelant à l’utilisateur qu’il se trouve dans le CMS de quelqu’un d’autre.
Cette image n’est pas fausse — c’est le comportement par défaut. Mais ce n’est pas la seule façon d’utiliser WordPress.
Je développe un SaaS à destination des professeurs de portugais (pensez à Linear, mais pour la gestion des notes). Le produit doit avoir l’apparence d’une application web moderne : des URL propres, un écran de connexion personnalisé, aucun élément de l’interface WordPress visible. Je souhaite en même temps bénéficier de ce que WordPress offre gratuitement : la gestion des utilisateurs, l’authentification, la compatibilité d’hébergement, l’infrastructure de l’API REST, le système cron et le processus de déploiement.
Cet article explique comment j’ai fait de WordPress le moteur du produit sans en faire le visage.
Ce que « WordPress caché » signifie concrètement
L’utilisateur ouvre app.example.com/app/ (ou example.com/app/, selon la stratégie de domaine) et voit un écran de connexion aux couleurs du produit — aucun logo WordPress, aucun formulaire wp-login. Une fois connecté, il se retrouve dans un SPA accessible à /app/dashboard, /app/classes/7a, /app/students/123. Les URL sont partageables. Les boutons précédent/suivant du navigateur fonctionnent. La page ne se recharge jamais lors de la navigation.
Il n’y a pas de barre d’administration. Il n’y a pas de /wp-admin/. Si l’utilisateur saisit accidentellement wp-login.php, il est redirigé vers l’écran de connexion propre à l’application. Le seul endroit où WordPress reste visible, c’est l’URL des appels à l’API REST dans l’onglet Réseau — et même ceux-ci sont préfixés par un espace de noms (/wp-json/avaliar/v1/...).
Pour y parvenir, quatre éléments doivent fonctionner ensemble :
- Une règle de réécriture personnalisée qui intercepte
/app/*avant le routage par défaut de WordPress - Un template qui affiche un unique point de montage React avec la configuration injectée
- Un espace de noms d’API REST personnalisé avec authentification par cookie et nonce
- La suppression de la barre d’administration, le blocage de wp-admin pour les utilisateurs non administrateurs, et la redirection de wp-login
Passons en revue chacun de ces points.
Règles de réécriture personnalisées : intercepter avant que WordPress ne prenne la main
Le système de routage des URL de WordPress est régi par le système de règles de réécriture. Par défaut, toute URL qui ne correspond pas à un article ou une page connu aboutit à une erreur 404. L’objectif est d’indiquer à WordPress que /app/* est spécial — c’est le SPA, défense d’y toucher.
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 . '/');
}
}
Les deux règles de réécriture sont intentionnelles. La première correspond à /app et /app/. La seconde correspond à /app/n'importe/quoi. Toutes deux redirigent vers index.php avec la variable de requête avaliar_app=1, qui constitue le marqueur signifiant « il s’agit d’une requête SPA ».
La priorité 'top' est essentielle. Sans elle, la règle par défaut de WordPress /(.+)/?$ (qui capture tout comme un slug d’article potentiel) pourrait intercepter votre URL en premier.
Le hook template_redirect se déclenche après que WordPress a résolu la requête, mais avant qu’un quelconque gabarit ne soit rendu. Si le marqueur est défini, le code masque la barre d’administration, effectue le rendu du SPA et exécute exit — empêchant ainsi WordPress d’effectuer toute autre action.
L’écueil redirect_canonical
Il existe une fonctionnalité subtile de WordPress qui a cassé mon SPA la première fois. Lorsque les permaliens sont configurés sur /%postname%/ (qui est le réglage standard en production), WordPress exécute un hook redirect_canonical qui ajoute des barres obliques finales aux URL qui n’en ont pas.
Cela semble anodin. Sauf que : si un utilisateur navigue vers /app/criteria?config=1 (sans barre oblique finale avant la chaîne de requête), WordPress voit un chemin sans barre oblique finale et tente de rediriger vers /app/criteria/?config=1. Parfois, la redirection altère la chaîne de requête. Parfois, elle effectue un rechargement complet de la page qui casse l’état du SPA. Dans tous les cas, des dysfonctionnements intermittents.
Le correctif tient en un seul filtre :
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;
}
Nous indiquons à WordPress : pour toute URL sous /app/*, ne pas tenter de canonicaliser. Le SPA est souverain sur ses propres routes.
Le gabarit de rendu : un point de montage unique
Lorsque /app/* correspond, le moteur de rendu produit un document HTML minimal avec un point de montage React ainsi que la configuration dont le SPA a besoin pour s’initialiser :
// 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,
];
}
}
Le gabarit lui-même est concis :
<!-- 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 lit window.__AVALIAR__ au démarrage, sait si l’utilisateur est authentifié et connaît l’URL de base pour les appels API. À partir de là, React Router prend le relais — l’utilisateur navigue côté client et ne sollicite le serveur que lorsque la SPA récupère des données via REST.
Vite + WordPress : la gestion des assets en développement et en production
Deux défis se posent ici :
En développement, le code React se trouve dans assets/src/app.tsx et est servi par le serveur de développement Vite sur le port 5173 (avec HMR). WordPress sert l’enveloppe HTML mais doit savoir où trouver les assets de développement.
En production, Vite compile des bundles optimisés dans assets/build/, et WordPress doit lire le manifeste (assets/build/.vite/manifest.json) pour savoir quels fichiers JS/CSS hachés charger.
Le helper ViteAssets gère les deux cas :
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 configuration Docker pour le développement : WordPress s’exécute dans un conteneur sur le port 8080. Vite s’exécute sur l’hôte sur le port 5173 avec npm run dev. Les deux utilisent un volume partagé afin que Vite puisse écrire le fichier hot que WordPress lit.
API REST avec un espace de noms personnalisé
La SPA appelle /wp-json/avaliar/v1/classes, et non /wp-json/wp/v2/posts. L’espace de noms personnalisé sépare les points de terminaison de l’application des points de terminaison du cœur de WordPress, et permet au plugin de définir ses propres règles de permissions.
Un contrôleur ressemble à ceci :
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),
]);
}
}
Le permission_callback est l’endroit où réside la vérification de l’authentification. Le mien ressemble à :
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 gère automatiquement la vérification cookie + nonce lorsque la SPA envoie X-WP-Nonce: <nonce> avec chaque requête. Le nonce provient de la configuration d’amorçage injectée au moment du rendu.
Bloquer wp-admin et wp-login pour les non-administrateurs
L’utilisateur ne devrait jamais voir wp-admin. S’il n’est pas administrateur WordPress, toute tentative d’accès à /wp-admin/* est redirigée vers /app/. Toute tentative d’accès à /wp-login.php (sauf pour la réinitialisation du mot de passe, qui utilise un flux personnalisé dans notre SPA) est redirigée vers /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;
}
}
La dernière pièce du puzzle : masquer la barre d’administration même pour les administrateurs, lorsqu’ils se trouvent dans le SPA :
// Inside Router::maybeHandle()
show_admin_bar(false);
Combiné avec exit après le rendu du gabarit, WordPress n’a jamais l’occasion d’injecter le balisage de sa barre d’administration.
Le résultat, en URLs
Avant :
/wp-admin/admin.php?page=my-tool— WordPress visible/wp-login.php— WordPress visible- Barre d’administration WordPress sur chaque page
- URLs au format
?p=123
Après :
/app/— page de connexion ou tableau de bord personnalisés/app/classes/7a— route SPA propre/app/students/123— URL partageable et directement accessible- Pas de barre d’administration, pas d’interface wp-admin
- L’utilisateur n’a aucune idée que WordPress existe, sauf s’il ouvre les outils réseau des DevTools
Quand cette approche est pertinente (et quand elle ne l’est pas)
Pertinente lorsque :
- Vous souhaitez utiliser l’infrastructure utilisateur/authentification/hébergement de WordPress sans que les utilisateurs voient l’interface de WordPress
- Le produit est suffisamment interactif pour justifier une SPA (formulaires, tableaux de bord, mises à jour en temps réel)
- Vous ou votre équipe êtes à l’aise avec PHP et un pipeline de build JS
- Vous pouvez vous engager à maintenir la base de code WordPress à jour pour des raisons de sécurité
Déconseillé quand :
- Le produit est à forte dominante éditoriale (blogs, sites marketing) — laissez WordPress faire ce qu’il fait bien
- L’équipe est exclusivement JS et considère PHP comme un obstacle — choisissez un autre backend
- Vous avez besoin de performances extrêmes à la périphérie — WordPress + PHP + MySQL génère une certaine surcharge
- Vous êtes sur un hébergement mutualisé qui ne prend pas en charge les règles de réécriture personnalisées ou les versions modernes de PHP
Ce que j’ai appris
WordPress est plus flexible que sa réputation ne le laisse entendre. Avec les règles de réécriture, les espaces de noms REST personnalisés et des hooks bien maîtrisés, vous pouvez construire un produit qui ne ressemble en rien à un site WordPress classique. Le discours communautaire se concentre sur les thèmes et les plugins, mais la plateforme principale est bien plus capable que cela.
Le comportement par défaut vous complique la vie sur les petites choses. L’injection de la barre d’administration, la canonicalisation des redirections, la personnalisation du formulaire de connexion — chacun de ces points doit être explicitement désactivé ou remplacé. Documentez chaque écueil au moment où vous le découvrez ; vous ne vous souviendrez plus pourquoi vous avez écrit ce filtre.
Vite + WordPress forment un duo productif. L’expérience de développement (HMR, rebuilds rapides) est ce qu’attendent les équipes frontend modernes. La sortie en production (pilotée par le manifest, assets hachés, bundling optimal) répond aux exigences de la production. La colle PHP entre les deux est légère et s’écrit d’elle-même une fois pour toutes.
Le nommage des espaces de noms REST est plus important qu’on ne le croit. /wp-json/avaliar/v1/... envoie un signal fort — aux autres développeurs, aux outils de débogage, aux générateurs de documentation d’API — indiquant qu’il s’agit de fonctionnalités applicatives et non du cœur de WordPress. Ne cédez pas à la tentation d’enregistrer vos endpoints sous wp/v2/.
Si vous envisagez cette approche pour votre propre produit — construire un SaaS avec WordPress comme moteur mais sans son interface — je peux vous aider à réfléchir à l’architecture. Le schéma est identique d’un produit à l’autre ; les points délicats résident dans les spécificités de votre modèle d’authentification, de votre structure de données et de vos priorités en matière d’expérience utilisateur.