How to use WordPress as backend infrastructure for a modern SaaS, with a React SPA on a custom public route, no wp-admin, no wp-login.php, and clean URLs that look like Notion or Linear.
When you tell a developer you’re building a SaaS on WordPress, the reaction is usually somewhere between scepticism and pity. The mental image is /wp-admin/admin.php?page=my-tool, a wp-login.php form with the WordPress logo, and an admin bar at the top of every page reminding the user they’re inside someone else’s CMS.
That mental image isn’t wrong — it’s the default. But it isn’t the only way to use WordPress.
I’m building a SaaS for Portuguese teachers (think Linear, but for grade management). The product needs to feel like a modern web application: clean URLs, custom login screen, no WordPress chrome anywhere. At the same time, I want the things WordPress gives me for free: user management, authentication, hosting compatibility, REST API infrastructure, the cron system, the deployment story.
This article shows how I made WordPress the engine without making it the face of the product.
What “hidden WordPress” actually means
The user opens app.example.com/app/ (or example.com/app/, depending on the domain strategy) and sees a login screen with the product’s branding — no WordPress logos, no wp-login form. After login, they’re in a SPA at /app/dashboard, /app/classes/7a, /app/students/123. URLs are linkable. The browser back/forward buttons work. The page never reloads when they navigate.
There’s no admin bar. There’s no /wp-admin/. If they accidentally type wp-login.php, they get redirected to the application’s own login. The only place WordPress is visible is the URL of REST API calls in the Network tab — and even those are namespaced (/wp-json/avaliar/v1/...).
To get there, four things need to work together:
- A custom rewrite rule that intercepts
/app/*before WordPress’s default routing - A template that renders a single React mount point with config injected
- A custom REST API namespace with cookie + nonce authentication
- Hiding the admin bar, blocking wp-admin for non-admin users, and redirecting wp-login
Let me walk through each.
Custom rewrite rules: intercepting before WordPress takes over
WordPress’s URL routing is governed by the rewrite rules system. By default, any URL that doesn’t match a known post or page falls through to a 404. The goal is to teach WordPress that /app/* is special — it’s the SPA, hands off.
declare(strict_types=1);
namespace Avaliar\App;
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 . '/');
}
}
The two rewrite rules are deliberate. The first matches /app and /app/. The second matches /app/anything/at/all. Both forward to index.php with the query var avaliar_app=1, which is the flag for “this is an SPA request”.
The 'top' priority is critical. Without it, WordPress’s default /(.+)/?$ rule (which matches everything as a potential post slug) might catch your URL first.
The template_redirect hook fires after WordPress has resolved the request but before any template is rendered. If the flag is set, the code hides the admin bar, renders the SPA, and exits — preventing WordPress from doing anything else.
The redirect_canonical gotcha
There’s a subtle WordPress feature that broke my SPA the first time around. With permalinks set to /%postname%/ (which is the standard production setting), WordPress runs a redirect_canonical hook that adds trailing slashes to URLs that don’t have them.
Sounds harmless. Except: if a user navigates to /app/criteria?config=1 (no trailing slash before the query string), WordPress sees a path without the trailing slash and tries to redirect to /app/criteria/?config=1. Sometimes the redirect mangles the query string. Sometimes it does a full-page reload that breaks the SPA’s state. Either way, intermittent breakage.
The fix is one filter:
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;
}
We tell WordPress: for any URL under /app/*, don’t try to canonicalise. The SPA is sovereign in its own routes.
The render template: a single mount point
When /app/* matches, the renderer outputs a minimal HTML document with a React mount point and the configuration the SPA needs to bootstrap:
// 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,
];
}
}
The template itself is small:
<!-- 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>
The SPA reads window.__AVALIAR__ at boot, knows whether the user is authenticated, and knows the base URL for API calls. From there, React Router takes over — the user navigates client-side, only hitting the server when the SPA fetches data via REST.
Vite + WordPress: the dev/prod asset story
Two challenges here:
In development, the React code lives in assets/src/app.tsx and gets served by Vite’s dev server on port 5173 (with HMR). WordPress serves the HTML wrapper but needs to know where to find the dev assets.
In production, Vite builds optimised bundles into assets/build/, and WordPress needs to read the manifest (assets/build/.vite/manifest.json) to know which hashed JS/CSS files to load.
The ViteAssets helper handles both:
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'] ?? []
),
];
}
}
The Docker setup for development: WordPress runs in a container on port 8080. Vite runs on the host on port 5173 with npm run dev. Both use a shared volume so Vite can write the hot file that WordPress reads.
REST API with a custom namespace
The SPA calls /wp-json/avaliar/v1/classes, not /wp-json/wp/v2/posts. The custom namespace separates application endpoints from any WordPress core endpoints, and lets the plugin define its own permission rules.
A controller looks like this:
namespace Avaliar\Api;
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),
]);
}
}
The permission_callback is where the auth check lives. Mine looks like:
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 handles the cookie+nonce verification automatically when the SPA sends X-WP-Nonce: <nonce> with each request. The nonce came from the bootstrap config injected at render time.
Blocking wp-admin and wp-login for non-admins
The user should never see wp-admin. If they’re not a WordPress administrator, any attempt to reach /wp-admin/* redirects to /app/. Any attempt to reach /wp-login.php (other than for password reset, which uses a custom flow in our SPA) redirects to /app/login:
namespace Avaliar\Core;
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;
}
}
The final piece: hide the admin bar even for administrators, when they’re inside the SPA:
// Inside Router::maybeHandle()
show_admin_bar(false);
Combined with exit after rendering the template, WordPress never gets the chance to inject its admin bar markup.
The result, in URLs
Before:
/wp-admin/admin.php?page=my-tool— visible WordPress/wp-login.php— visible WordPress- WordPress admin bar on every page
?p=123style URLs
After:
/app/— branded login or dashboard/app/classes/7a— clean SPA route/app/students/123— linkable, shareable- No admin bar, no wp-admin chrome
- The user has no idea WordPress exists unless they open Network DevTools
When this approach is right (and when it isn’t)
Right when:
- You want WordPress’s user/auth/hosting infrastructure but don’t want users seeing WP chrome
- The product is interactive enough to justify a SPA (forms, dashboards, real-time updates)
- You or your team are comfortable with both PHP and a JS build pipeline
- You can commit to keeping the WordPress codebase up to date for security
Wrong when:
- The product is content-heavy (blogs, marketing sites) — let WordPress do what it’s good at
- The team is JS-only and finds PHP a barrier — pick a different backend
- You need extreme performance at the edge — WordPress + PHP + MySQL has overhead
- You’re on shared hosting that doesn’t support custom rewrite rules or modern PHP
What I learned
WordPress is more flexible than its reputation suggests. With rewrite rules, custom REST namespaces, and disciplined hooks, you can build a product that feels nothing like a typical WordPress site. The community discourse focuses on themes and plugins, but the core platform is much more capable than that.
The default behaviour fights you on the small things. Admin bar injection, redirect canonicalisation, login form theming — each of these has to be explicitly turned off or replaced. Document each gotcha when you discover it; future-you will not remember why you wrote that filter.
Vite + WordPress is a productive pairing. The dev experience (HMR, fast rebuilds) is what modern frontend teams expect. The production output (manifest-driven, hashed assets, optimal bundling) is what production needs. The PHP glue between them is small and writes itself once.
REST namespacing is more important than people give it credit for. /wp-json/avaliar/v1/... is a strong signal — to other developers, to debuggers, to API documentation generators — that this is application-level functionality, not WordPress core. Don’t be tempted to register your endpoints under wp/v2/.
If you’re considering this approach for your own product — building a SaaS with WordPress as the engine but not the face — I can help you think through the architecture. The pattern is the same across products; the tricky bits are in the specifics of your auth model, your data shape, and your user experience priorities.