Skip to content

Repository files navigation

RenderX

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.

What It Does

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

Quick Setup

Prerequisites

  • Docker

Configuration

  1. 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"
        }
    ]
}

To keep SSR on but serve specific routes as pure CSR, list them in ssrExclude. Useful for logged-in areas that have no SEO value and should not be rendered or cached:

{
    "hosts": [
        {
            "source": "my-app",
            "host": "my-app.com",
            "ssrExclude": ["/app", "/app/*"]
        }
    ]
}

Excluded routes are served straight from index.html, with no render and no cache entry. Patterns use the same * wildcard as host, and * spans /, so /app/* also covers /app/settings/billing. Set ssrExclude at the top level to apply it to every host; a host-level list replaces the global one.

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.com matches all subdomains like app.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
    ssrExclude?: string[]
}

interface GlobalConfig {
    port?: number
    parallelRenders?: number
    cacheCleanupInterval?: number
    ssr?: boolean
    ssrExclude?: string[]
    hosts?: HostConfig[]
    logs?: 'none' | 'ssr' | 'all'
    logFormat?: 'text' | 'json'
    timeoutMs?: number
}
  1. Add your app:
mkdir -p hosts/my-app
# Copy your built SPA files to hosts/my-app/

Run with Docker

RenderX is distributed as a Docker image and runs exclusively in Docker containers.

Basic Usage:

Pull the Docker image:

docker pull neilveil/renderx

Run the container:

docker run -p 8080:8080 \
  -v $(pwd)/hosts:/app/hosts \
  -v $(pwd)/config.json:/app/config.json \
  neilveil/renderx

Testing:

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 local hosts/ 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_SERVICE and map port 80. The --cap-add=NET_BIND_SERVICE flag 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 files
  • config.json: Configuration file

Server runs on http://localhost (port 80)

How It Works

  1. Host resolution: the Origin header if present, otherwise Host. A host that matches no config is refused with 403.
  2. File resolution: inside that host's source directory and nowhere else.
  3. SSR check: If ssr is disabled or the path matches ssrExclude, serve static files. Otherwise, check cache.
  4. Cache check: Fresh hit serves instantly. Stale hit serves instantly and triggers a background re-render. Miss triggers a full render.

Host Isolation

Each host resolves to exactly one directory. Hosts never fall back to one another, so a file present in one host's source is not reachable from another host's domain, and the order of the hosts array never affects what a given host serves.

A path that names a file (it has an extension) and matches nothing returns 404 — it is never answered with index.html. Extensionless paths do fall through to the host's index.html, which is what makes client-side routing work. Share an asset between two sites by copying it into both builds.

Request Flow

flowchart TD
    A[Request arrives] --> B{Host configured?}
    B -->|No| C[403]
    B -->|Yes| D{File in this<br>host's directory?}
    D -->|Yes| E[Serve file]
    D -->|No| F{Path has<br>an extension?}
    F -->|Yes| G[404]
    F -->|No| H{SSR enabled and<br>path not excluded?}
    H -->|No| I[Serve index.html]
    H -->|Yes| J{Check cache}
    J -->|Fresh hit| K[Serve cached HTML]
    J -->|Stale hit| L[Serve cached HTML<br>and re-render in background]
    J -->|Miss| M[Render with headless browser]
    M --> N[Cache and serve]
Loading

Configuration Options

Global Settings

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).
ssrExclude array No [] Route glob patterns served as pure CSR even when ssr is enabled (e.g. ["/app", "/app/*"]). No render, no cache entry.
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)

Host Settings

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.
ssrExclude array No [] Route glob patterns served as pure CSR even when ssr is enabled (e.g. ["/app", "/app/*"]). Overrides the global list. * spans /, so /app/* also covers /app/settings/billing.
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.

Environment Variables

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 -

Caching

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.

Logging

Log Labels

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

Log Format

  • text (default in development): Human-readable with emojis and timestamps
  • json (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.

Log Levels

  • "none": No request logs
  • "ssr": Only SSR, SSR-CACHE, and SSR-REFRESH logs (default)
  • "all": All logs including STATIC

Render Queue

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.

Wait Strategy

RenderX always waits for network idle to ensure pages are fully loaded.

Releasing

Images are published to neilveil/renderx by CI when a prod-v{version} tag is pushed. Both linux/amd64 and linux/arm64 are built, and the release is tagged {version} and latest.

# 1. bump "version" in package.json, then commit it
# 2. tag and push
git tag prod-v1.1.0
git push origin prod-v1.1.0

The workflow refuses to publish when the tag version does not match package.json, or when that version is already on Docker Hub — image tags are mutable, and deployments pin them.

To publish by hand when CI is unavailable:

./build.sh 1.1.0

License

MIT


Created by @neilveil

About

No description, website, or topics provided.

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages