Skip to main content

Hybrid Rendering: Client, SSG, and SSR

One Runtime, Three Initial Render Modes

Integrated ReactWP themes can choose an initial render mode for each registered template.

ModeInitial HTMLNode in productionTypical use
clientEmpty #app, rendered in the browserNoExisting ReactWP projects, highly interactive screens
staticHTML fragment generated ahead of time and hydratedNoHome, About, services, articles, public landing pages
serverHTML fragment rendered for the request and hydratedYesAccounts, carts, personalized or request-dependent screens

client remains the default. Existing registerTemplate(name, loader) registrations keep their current behavior.

WordPress remains the front controller in every mode. It resolves the permalink, status, language, route data, menus, SEO, cookies, and plugins. SSG produces internal React fragments rather than public standalone .html pages. PHP selects the correct fragment and injects it into #app; React then hydrates that markup.

Configure a Template

Use src/themes/<theme>/js/inc/config/configureTemplateRegistry.js.

Client Rendering

The original signature is unchanged:

registerTemplate('Search', () => import('../../templates/Search'));

The explicit form is equivalent:

registerTemplate('Search', {
loader: () => import('../../templates/Search'),
render: 'client'
});

Static Rendering

registerTemplate('Home', {
loader: () => import('../../templates/Home'),
render: 'static',
cache: {
payload: true,
media: true,
tags: ['post-type:project']
}
});

Server Rendering

Personalized HTML should not use a shared cache:

registerTemplate('Account', {
loader: () => import('../../templates/Account'),
render: 'server',
cache: {
html: false,
payload: false
}
});

A public SSR route can opt into a short shared cache:

registerTemplate('LiveInventory', {
loader: () => import('../../templates/LiveInventory'),
render: 'server',
cache: {
html: true,
scope: 'public',
ttl: 60,
tags: ['post-type:product']
}
});

When a registry name differs from its template filename, set assetKey so WordPress can enqueue the extracted template CSS before first paint:

registerTemplate('HomeTemplate', {
loader: () => import('../../templates/Home'),
assetKey: 'Home',
render: 'static'
});

The render build writes assets/render/templates.json. WordPress reads that generated manifest, so the JavaScript registry is the normal source of template mode configuration.

Route-Level Overrides

When ACF is active, ReactWP adds a React Rendering field group to supported content screens. An editor or developer can override the template default for one route:

  • Initial render: template default, client, static, or server
  • SSR cache scope: private per user or public guests
  • SSR cache TTL: 0 disables SSR HTML caching

PHP filters can provide project-wide or conditional overrides:

add_filter('rwp_render_templates', function($templates){
$templates['Home'] = [
'mode' => 'static',
'cache' => [
'tags' => ['post-type:project'],
],
];

return $templates;
});

The final extension points are:

  • rwp_render_templates
  • rwp_render_config
  • rwp_render_mode

The generated registry manifest provides the default, PHP filters can override it, and route-level ACF values have the final content-specific override before the final filters run.

Render Build Output

Theme builds now create:

assets/render/
|-- server.cjs
|-- serve.mjs
|-- templates.json
|-- template-assets.json
`-- chunks/

server.cjs contains the universal React renderer. serve.mjs is the optional HTTP service. The two manifests connect the React registry and extracted template CSS back to WordPress.

Useful commands from configs/:

npm run build:render
npm run watch:render
npm run prod:render
npm run test:render

The normal build, watch, and prod pipelines already include the render build.

Generate Static Routes

Build the renderer, then point the generator at WordPress:

$env:RWP_SITE_URL = 'https://example.com'
npm run generate

Or pass the URL directly:

npm run generate -- --site=https://example.com

The generator reads the public sitemap and requests a route-aware bootstrap for each path. It renders only routes whose final mode is static and writes:

assets/render/static/
|-- manifest.json
`-- fragments/

Set RWP_SITE_URL before npm run prod to run SSG automatically after a successful production build. Without that variable, production prints a skip message and completes normally.

The default sitemap includes published public post types. Use rwp_headless_sitemap_items to add an archive, virtual route, or other path that should participate in SSG.

Category and Taxonomy Routes

Category and taxonomy term archives are resolvable ReactWP routes, but they are not enumerated by the default sitemap. Add their paths before static generation. This example adds every non-empty WordPress category:

add_filter('rwp_headless_sitemap_items', function($items) {

$terms = get_terms([
'taxonomy' => 'category',
'hide_empty' => true,
]);

if(is_wp_error($terms)){
return $items;
}

foreach($terms as $term){
$url = get_term_link($term);

if(is_wp_error($url)){
continue;
}

$items[] = [
'id' => 'term_' . $term->term_id,
'type' => 'term',
'taxonomy' => $term->taxonomy,
'title' => $term->name,
'url' => $url,
'path' => wp_parse_url($url, PHP_URL_PATH) ?: '/',
];
}

return $items;

});

The resolved term route must also select a template registered as static:

registerTemplate('CategoryTemplate', {
loader: () => import('../../templates/Category'),
render: 'static'
});

Registering the template does not assign it to a category. Choose one of these assignment strategies:

  1. Edit each category in WordPress and enter CategoryTemplate in its React Template field.
  2. Force the template for every category route at the project level:
add_filter('rwp_route_payload', function($payload, $object) {

if($object instanceof WP_Term && $object->taxonomy === 'category'){
$payload['template'] = 'CategoryTemplate';
}

return $payload;

}, 10, 2);

The route payload filter runs before ReactWP resolves the render strategy, so CategoryTemplate receives its registered static mode. A term without a field value or project-level override uses Default.

Run npm run generate or a production build with RWP_SITE_URL afterward. Every included term whose final render mode is static is written to static/manifest.json. Replace category with a custom public taxonomy slug to generate its term archives.

Deploy the static manifest and fragments with the rest of the theme. Node is not needed to serve them.

Run the Optional SSR Service

Build production output, set a secret, and start the service:

$env:RWP_SSR_SECRET = 'replace-with-a-long-random-secret'
npm run serve:ssr

Configure WordPress in wp-config.php:

define('RWP_SSR_ENDPOINT', 'http://127.0.0.1:3100/render');
define('RWP_SSR_SECRET', getenv('RWP_SSR_SECRET'));

Use a process manager appropriate for the server to keep serve.mjs running. The service defaults to 127.0.0.1:3100, limits request size and concurrent renders, enforces the configured secret, and exposes GET /health.

ReactWP rejects non-loopback renderer URLs by default. A remote renderer requires both transport security and an explicit rwp_ssr_allow_remote_endpoint opt-in.

If the service is unavailable, times out, or returns invalid output, WordPress serves the normal client-rendered route. A short circuit breaker prevents every request from repeatedly waiting on a failed service.

Static Revalidation

Generated fragments carry dependency tags. ReactWP invalidates matching tags when posts, terms, menus, ACF options, or the global ReactWP cache generation change.

Default fragment tags include:

  • render:all
  • template:<name>
  • post:<id> when the route has a post ID
  • menu:all
  • settings:all

Add project dependencies such as post-type:project in the template cache configuration. Saving a project then invalidates every static fragment that declares that dependency.

When the SSR service is configured, WordPress queues affected routes through WP-Cron, renders them with the updated WordPress payload, and writes protected runtime fragments under:

wp-content/uploads/reactwp/render/static/

Runtime fragments override build fragments. ReactWP adds index.php files and an Apache deny rule because these files are internal render artifacts. Configure the equivalent deny rule on Nginx.

Without Node in production, invalidated fragments are never served stale. The route temporarily falls back to client until the next static generation or deployment.

Invalidate custom dependencies from PHP:

rwp::invalidate_render_cache([
'post-type:project',
'settings:all',
]);

The ReactWP > Cache action advances the browser cache generation and invalidates all static and SSR HTML entries.

See Cache Tags for the complete automatic-tag table, custom tag format, PHP invalidation hooks, and mode-specific behavior.

Cache Controls

The render mode and cache layers are independent.

SettingApplies toMeaning
cache.htmlSSRenable server-rendered fragment caching
cache.scopeSSRprivate per user or public for guests
cache.ttlSSRHTML lifetime in seconds
cache.payloadbrowser route servicereuse route payload in the current tab
cache.mediabrowser media loaderuse versioned Cache Storage
cache.tagsstatic and SSR HTMLdependencies used for targeted invalidation

Static mode necessarily stores its generated fragment. Its payload and media caches can still be disabled independently. Client mode has no server HTML cache but can use payload and media caching.

Public SSR cache is bypassed for logged-in users. Private SSR cache keys include the current WordPress user ID and are disabled for anonymous visitors unless rwp_ssr_cache_identity supplies a project-specific session identity. Keep carts, accounts, previews, nonces, and session-specific output private or uncached.

Universal Template Rules

Static and server templates run once without a browser. Keep render-time code universal:

  • read window, document, layout, and browser storage inside effects or guarded event handlers
  • do not create random IDs, timestamps, or locale-dependent output during render unless the same value is in the payload
  • keep the first client render structurally identical to the server render
  • initialize GSAP, observers, carousels, and DOM measurements in effects with cleanup
  • use currentUser or project data supplied through the bootstrap instead of reading cookies from React

The standard template props are:

  • route
  • site
  • theme
  • system
  • navigation
  • currentUser

currentUser.authenticated is always available. Extend the integrated value with rwp_current_user_payload, and extend other project data through rwp_bootstrap or rwp_ssr_payload.

Loader and Hydration

Direct static/server requests receive data-rwp-render="static" or server on #app. ReactWP preloads the initial template and calls hydrateRoot. Client routes continue to use createRoot.

Pre-rendered routes enqueue their extracted template CSS before wp_head() and skip the full-screen initial loader by default. Keep the loader for a project that requires imperative critical-media insertion:

add_filter('rwp_prerender_skip_loader', '__return_false');

If hydration cannot prepare the template, ReactWP clears the fragment and starts the normal client application.

Deployment Choices

No Node in Production

  • use client routes
  • deploy build-generated static fragments
  • invalidated static routes fall back to client until the next build
  • never configure RWP_SSR_ENDPOINT

Node at Build Time Only

  • generate static routes locally or in CI
  • deploy WordPress, assets, manifest, and fragments
  • no Node process is required on the web server

Node in Production

  • keep static routes for stable public pages
  • use server routes for request-dependent pages
  • enable targeted runtime static regeneration
  • retain client mode as the automatic failure fallback

These choices can coexist in one theme and can be changed one template or route at a time.