Flx Render (Twenty Twenty-Five)
Version 1.0.0 · Node.js 22 · Express 5 · TypeScript · GPL-2.0-or-later
Flx Render turns the page data that the Flx Modernize plugin sends into a finished HTML page in the site's theme. It is stateless: no database, no sessions, no stored content. This package renders sites that use the Twenty Twenty-Five theme.
Architecture
Visitor ──GET /hello-world/──▶ WordPress + Flx Modernize
│ builds page data (as an anonymous visitor)
│ POST /api/v1/render/post (signed JSON)
▼
Flx Render ──GET theme CSS──▶ WordPress REST (/wp-json/flx-modernize/v1/…)
│ returns { success, html }
▼
Visitor ◀──HTML (Cache-Control: private, no-store)── WordPress
- Push model: WordPress sends everything the page needs; the renderer never queries the WordPress database.
- One exception: the theme's CSS. The payload carries links to the site's global-styles and font CSS. The renderer fetches them (2-second timeout, cached for 24 hours per URL, failures retried after 30 seconds) and inlines them into the page.
- Fallback: the renderer never decides what is correct. If it fails, the plugin serves the WordPress page.
Routes
| Method and path | Purpose | Auth |
|---|---|---|
POST /api/v1/render/page |
a page | signed |
POST /api/v1/render/post |
a single post | signed |
POST /api/v1/render/archive |
blog home page, category and tag archives | signed |
POST /api/v1/render/search |
search results | signed |
POST /api/v1/render/404 |
the 404 page | signed |
GET /health, /liveness, /readiness |
status for monitoring | public, no details |
GET /static/* |
the renderer's own CSS and the mobile-menu script | public |
A render route answers 200 {"success": true, "html": "…"}, 400 for an invalid payload, or 401 for an authentication failure ({"error": "<code>", "message": "…"}, never naming the key).
The data it receives
All fields are plain JSON. HTML fields (content) arrive already rendered by WordPress; text fields are escaped by the renderer.
| Field | Page | Post | Archive | Search | 404 |
|---|---|---|---|---|---|
title, site_name |
✓ | ✓ | ✓ | (from query) |
optional |
permalink / url |
✓ | ✓ | ✓ | ✓ | |
content |
✓ | ✓ | |||
date, author, slug, excerpt |
✓ | ||||
items[] (title, url, excerpt, date) and pagination |
✓ | ✓ | |||
query |
✓ | ||||
message, home_url |
optional | ||||
featured_image (url, width, height, alt) |
optional | optional | |||
navigation.primary[] (label, url, current) |
optional | optional | optional | optional | optional |
global_styles, theme_assets.fonts (CSS links) |
optional | optional | optional | optional | optional |
There are no cookies, no user data and no visitor query strings. The search words are the one piece of visitor input, sent because the results page shows them. The full type definitions are in src/platforms/wp/twentytwentyfive/*/types.ts.
How pages are built
src/lib/layout.ts assembles the document: <head> with the renderer's stylesheet, then the inlined theme CSS, then the header, main content and footer. The Twenty Twenty-Five module (src/platforms/wp/twentytwentyfive/) supplies the theme's own markup:
| File | Theme part it reproduces |
|---|---|
header.ts |
parts/header.html: site title and the primary menu (theme Navigation block classes, responsive overlay) |
footer.ts |
parts/footer.html: site title, two link columns, "Designed with WordPress" |
page/, post/ |
single page and post content |
archive/ (also used by search/) |
the query loop of home.html, archive.html, search.html |
error404/ |
templates/404.html: image, heading, message, search form |
Because the markup keeps the theme's block classes, the inlined global-styles CSS styles it exactly as on a native page. The few per-page layout rules WordPress normally prints inline (.wp-container-core-…) live in src/styles/tailwind.css, with doubled class selectors so they win over the theme's generic rules regardless of load order.
Configuration
| Variable | Required | Meaning |
|---|---|---|
FLX_RENDER_PUBLIC_URL |
yes on a server | the renderer's public URL, used for its own CSS and script links |
FLX_RENDER_SIGNING_KEYS |
yes | keyId:secret (comma-separate several keys) |
FLX_RENDER_AUTH_MODE |
yes | enforce in production; warn only logs failures |
FLX_RENDER_DEPLOYMENT |
local only | local allows a localhost URL when FLX_RENDER_PUBLIC_URL is unset |
PORT |
no | default 3000 (not used on Vercel) |
Deploying on Vercel
- Create a Vercel project from the package (or your repository), with Root Directory
renderer(or the package folder) and Node.js 22. Vercel detects Express; novercel.jsonis needed. - Set the Production environment variables above, with
FLX_RENDER_AUTH_MODE=enforce. - Add your domain, e.g.
yoursite-render.example.com. Point a DNS CNAME at Vercel; with Cloudflare, use DNS only. - Optional, to deploy only from
main: set Ignored Build Step toif [ "$VERCEL_GIT_COMMIT_REF" = "main" ]; then exit 1; else exit 0; fi. - Check it:
GET /healthgives 200, and an unsignedPOST /api/v1/render/pagegives 401.
Running on your own server
npm ci
npm run build
FLX_RENDER_PUBLIC_URL=https://render.example.com \
FLX_RENDER_SIGNING_KEYS=site1:$(cat /path/to/secret) \
FLX_RENDER_AUTH_MODE=enforce \
PORT=3000 node dist/server.js
Put it behind a TLS reverse proxy. The renderer must be able to reach the WordPress site's /wp-json/flx-modernize/v1/ endpoints.
Security
- Every render request must be signed (
FLX_RENDER_AUTH_MODE=enforce); use one key per site. - No diagnostics endpoints; health routes return no configuration, and errors never include secrets or key IDs.
- The renderer sets no cookies, and stores nothing.
- The replay-protection nonce store is per instance, so on serverless platforms a replay can be accepted by another instance within the 300-second window. Every request is still signed.
- Keep
npm auditat zero critical findings.
Development
npm ci
npm test # node:test, ~250 tests
npm run build # Tailwind CSS + TypeScript
npm run dev # local server with reload (FLX_RENDER_DEPLOYMENT=local)
Published from rickey29/twenty-twenty-five at commit 2541c1d · Flx Modernize 1.0.0 · Flx Render 1.0.0