On-the-fly HTML Prerender Service
RenderX renders your SPA pages on-the-fly and caches the result. Every visitor gets fully rendered HTML, instantly.
Modern single-page applications (SPAs) load content with JavaScript. Search engines and social media bots often see empty pages, hurting SEO and link previews.
RenderX solves this by:
- Rendering pages using a headless browser
- Caching results for speed (stale-while-revalidate)
- Refreshing cache in the background automatically
- Queuing renders with priority to prevent overload
- Serving static assets directly
- Docker
- Create config file (
config.json):
Create a config.json file. Most fields are optional and will use defaults if not specified. Minimal example:
{
"hosts": [
{
"source": "my-app",
"host": "my-app.com"
}
]
}SSR is enabled by default for all hosts. To disable SSR for a specific host (serve static files only):
{
"hosts": [
{
"source": "my-app",
"host": "my-app.com"
},
{
"source": "landing",
"host": "landing.com",
"ssr": false
}
]
}To disable SSR globally (e.g., while troubleshooting browser issues):
{
"ssr": false,
"hosts": [
{
"source": "my-app",
"host": "my-app.com"
}
]
}Glob Pattern Examples:
You can use glob patterns with * wildcards in the host field:
{
"hosts": [
{
"source": "my-app",
"host": "*.my-app.com"
},
{
"source": "catch-all",
"host": "*"
}
]
}*.my-app.commatches all subdomains likeapp.my-app.com,api.my-app.com,www.my-app.com*matches all domains (useful for catch-all configurations)- Exact matches (e.g.,
my-app.com) take priority over glob patterns
Note: The source field specifies the folder name within the global hosts/ directory that contains your SPA build files. For example, if source is "my-app", RenderX will look for your application files in hosts/my-app/.
Type definitions:
interface HostConfig {
source: string
host: string
isActive?: boolean
timeoutMs?: number
parallelRenders?: number
ssr?: boolean
}
interface GlobalConfig {
port?: number
parallelRenders?: number
cacheCleanupInterval?: number
ssr?: boolean
hosts?: HostConfig[]
logs?: 'none' | 'ssr' | 'all'
logFormat?: 'text' | 'json'
timeoutMs?: number
}- Add your app:
mkdir -p hosts/my-app
# Copy your built SPA files to hosts/my-app/RenderX is distributed as a Docker image and runs exclusively in Docker containers.
Basic Usage:
Pull the Docker image:
docker pull neilveil/renderxRun the container:
docker run -p 8080:8080 \
-v $(pwd)/hosts:/app/hosts \
-v $(pwd)/config.json:/app/config.json \
neilveil/renderxTesting:
First request (renders page):
curl http://localhost:8080 -H "Origin: https://my-app.com"Second request (served from cache, instant):
curl http://localhost:8080 -H "Origin: https://my-app.com"How it works:
-p 8080:8080: Maps port 8080 on your host machine to port 8080 inside the container (where RenderX runs)-v $(pwd)/hosts:/app/hosts: Mounts your localhosts/directory into the container so RenderX can access your SPA files-v $(pwd)/config.json:/app/config.json: Mounts your configuration file into the container
Port Configuration:
- Direct port 80: To use port 80 directly without a reverse proxy server, run Docker with
--cap-add=NET_BIND_SERVICEand map port 80. The--cap-add=NET_BIND_SERVICEflag grants the container permission to bind to privileged ports (ports below 1024) without running as root, which is needed since RenderX runs as a non-root user for security. This allows RenderX to bind to port 80 and handle all incoming HTTP traffic directly.
docker run --cap-add=NET_BIND_SERVICE -p 80:8080 \
-v $(pwd)/hosts:/app/hosts \
-v $(pwd)/config.json:/app/config.json \
neilveil/renderx- With reverse proxy: If you're using a reverse proxy server (nginx, Apache, Caddy, Traefik, etc.), use
-p 8080:8080(or any other port) and configure your reverse proxy to route traffic from port 80 to your chosen port where RenderX is listening.
Volume Mounts:
hosts/: Directory containing your SPA build filesconfig.json: Configuration file
Server runs on http://localhost (port 80)
- Request arrives with Origin header
- Host matching: Find the matching host config
- SSR check: If
ssris disabled, serve static files. Otherwise, check cache. - Cache check: Fresh hit serves instantly. Stale hit serves instantly and triggers a background re-render. Miss triggers a full render.
flowchart TD
A[Request Arrives] --> B{SSR Enabled?}
B -->|No| C[Serve Static File]
B -->|Yes| D{Check Cache}
D -->|Fresh Hit| E[Serve Cached HTML]
D -->|Stale Hit| F[Serve Cached HTML]
F --> G[Background Re-render]
D -->|Miss| H[Render with Headless Browser]
H --> I[Cache Result]
I --> J[Serve Rendered HTML]
All global settings are optional and will use defaults if not specified:
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
port |
number | No | 8080 |
Server port inside container |
ssr |
boolean | No | true |
Enable or disable SSR globally. Set to false to serve static files only (useful as a kill switch when SSR is failing). |
parallelRenders |
number | No | 10 |
Maximum number of parallel page renders |
cacheCleanupInterval |
number | No | 60 |
Cache cleanup interval in minutes. Also determines cache TTL: entries go stale after half this time, and are cleaned after 2x this time. |
logs |
string | No | "ssr" |
Logging level: "none" (no request logs), "ssr" (SSR/SSR-CACHE/SSR-REFRESH logs only), "all" (all logs including STATIC) |
logFormat |
string | No | "json" |
Log output format: "text" (human-readable with emojis) or "json" (structured for log aggregators). Defaults to "text" when NODE_ENV=development. |
hosts |
array | Yes | - | Array of host configurations (see Host Settings below) |
Each host configuration supports:
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
source |
string | Yes | - | The folder name within the global hosts/ directory that contains your SPA build files. For example, if source is "my-app", RenderX will look for files in hosts/my-app/. |
host |
string | Yes | - | Your domain name (e.g., "my-app.com"). Supports glob patterns with * wildcard:- * matches all domains- *.my-app.com matches all subdomains (e.g., app.my-app.com, api.my-app.com)- Exact matches take priority over glob patterns |
isActive |
boolean | No | true |
Set to true to enable this host |
ssr |
boolean | No | true |
Enable or disable SSR for this host. Overrides global setting. Set to false to serve static files only. |
timeoutMs |
number | No | 10000 |
Maximum time to wait for page load in milliseconds |
parallelRenders |
number | No | 10 |
Maximum parallel renders for this host. Overrides global setting. |
| Variable | Description | Default |
|---|---|---|
SSR |
Enable/disable SSR (true/false) |
true |
PORT |
Server port | 8080 |
PARALLEL_RENDERS |
Max parallel renders | 10 |
LOG_FORMAT |
Log format (text/json) |
json |
LOGS |
Log level (none/ssr/all) |
ssr |
TIMEOUT_MS |
Render timeout in ms | 10000 |
NODE_ENV |
Set to development for text log default |
- |
RenderX uses a stale-while-revalidate caching strategy:
- Fresh: Cache entry younger than half the TTL (e.g., <30 min with 60 min cleanup interval). Served instantly.
- Stale: Cache entry older than half the TTL but younger than 2x TTL. Served instantly, but a background re-render is triggered to refresh it.
- Expired: Cache entry older than 2x TTL. Cleaned up automatically. Next request triggers a full render.
The cacheCleanupInterval config (in minutes) controls the TTL. Periodic cleanup runs at this interval, removing entries older than 2x TTL.
Cache can be invalidated via container restart or the /cache/clear endpoint.
| Label | Meaning |
|---|---|
SSR |
Fresh render by headless browser (cache miss) |
SSR-CACHE |
Served from cache (fresh or stale) |
SSR-REFRESH |
Background re-render triggered |
STATIC |
File served directly |
text(default in development): Human-readable with emojis and timestampsjson(default in production): Structured JSON with fields:ts,method,host,path,status,strategy,duration,cache
Set via logFormat in config or LOG_FORMAT environment variable.
"none": No request logs"ssr": Only SSR, SSR-CACHE, and SSR-REFRESH logs (default)"all": All logs including STATIC
Requests are queued when the server is at render capacity (not rejected). Background stale-refreshes have lower priority than incoming real requests. If the queue overflows (3x parallelRenders), the server returns 503.
RenderX always waits for network idle to ensure pages are fully loaded.
MIT
Created by @neilveil