PhoenixPrerender

Static prerendering and incremental static regeneration (ISR) for Phoenix applications.

Generate static HTML from your Phoenix routes at build time, serve them instantly from disk in production, and keep them fresh with background regeneration — similar to Next.js ISR or SvelteKit prerendering, but built for the BEAM.

See the features in action at the Live demo

Contents

Features

Quick Start

1. Add the dependency

# mix.exs
defp deps do
  [
    {:phoenix_prerender, "~> 0.2.0"}
  ]
end

2. Mark routes for prerendering

In your router, mark routes with metadata: %{prerender: true}:

# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  scope "/", MyAppWeb do
    pipe_through :browser

    get "/about", PageController, :about, metadata: %{prerender: true}
    get "/pricing", PageController, :pricing, metadata: %{prerender: true}
    live "/docs/terms", TermsLive, metadata: %{prerender: true}

    # This route is NOT prerendered
    get "/contact", PageController, :contact
  end
end

Or use the prerender macro for cleaner syntax:

import PhoenixPrerender

scope "/", MyAppWeb do
  pipe_through :browser

  prerender do
    get "/about", PageController, :about
    get "/pricing", PageController, :pricing
    live "/docs/terms", TermsLive
  end

  # Not prerendered
  get "/contact", PageController, :contact
end

3. Add the serving plug

Add PhoenixPrerender.Plug to your endpoint, before the router:

# lib/my_app_web/endpoint.ex
defmodule MyAppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :my_app

  plug Plug.Static, ...

  # Serve prerendered pages when available
  plug PhoenixPrerender.Plug

  plug MyAppWeb.Router
end

4. Configure

# config/prod.exs
config :phoenix_prerender,
  enabled: true

5. Generate

mix phoenix.prerender

That's it. Marked routes are rendered through your full endpoint pipeline and written as static HTML files. In production, PhoenixPrerender.Plug serves them directly.

Configuration

All configuration lives under the :phoenix_prerender application key:

config :phoenix_prerender,
  # Whether the serving plug is active (default: false)
  enabled: false,

  # Directory for generated HTML files (default: "priv/static/prerendered")
  output_path: "priv/static/prerendered",

  # How URL paths map to files (default: :dir_index)
  #   :dir_index → /about → about/index.html
  #   :file      → /about → about.html
  url_style: :dir_index,

  # Cache-Control header for served pages (default: "public, max-age=300")
  cache_control: "public, max-age=300",

  # Metadata key/value for route discovery (defaults: :prerender / true)
  route_private_key: :prerender,
  route_private_value: true,

  # Concurrent rendering tasks (default: System.schedulers_online())
  concurrency: System.schedulers_online(),

  # Enable incremental static regeneration (default: false)
  isr: false,

  # Seconds before a page is considered stale (default: 300)
  revalidate: 300,

  # ISR strategy (default: :stale_while_revalidate)
  strategy: :stale_while_revalidate,

  # Base URL for sitemap.xml (default: "https://example.com")
  base_url: "https://example.com",

  # PubSub server for distributed cache invalidation (default: nil)
  pubsub: nil,

  # Only serve paths listed in manifest.json (default: true)
  strict_paths: true,

  # Compressor modules for pre-compression (default: [])
  compressors: [],

  # Prewarm ETS cache from manifest on boot (default: false)
  prewarm: false

Note: The output_path and url_style settings are shared between the mix task (which writes files) and the plug (which serves them). If you override either via CLI flags (--output, --style) without updating the config or plug options to match, the plug won't find the generated files. The safest approach is to set these values once in application config.

Guide

Configuration Guide

The Configuration reference above lists every option with its default. This section explains how to combine them for common deployment scenarios.

Minimal production setup

The simplest production configuration enables the serving plug and sets a base URL for sitemap generation:

# config/prod.exs
config :phoenix_prerender,
  enabled: true,
  base_url: "https://myapp.com"

Add the plug to your endpoint and run mix phoenix.prerender during your build. That's all you need for static serving.

ISR with cache prewarming

For sites that need pages to stay fresh without full rebuilds, enable ISR and prewarm the cache so the first request after a deploy is served from memory:

# config/prod.exs
config :phoenix_prerender,
  enabled: true,
  isr: true,
  revalidate: 300,
  prewarm: true,
  base_url: "https://myapp.com"
# lib/my_app/application.ex
children = [
  MyAppWeb.Endpoint,
  {PhoenixPrerender.PageCache, prewarm: true},
  {PhoenixPrerender.Regenerator, endpoint: MyAppWeb.Endpoint}
]

On boot, PageCache reads the manifest, loads all pages into ETS, and logs the count. ISR then keeps them fresh in the background.

Pre-compression for bandwidth savings

Enable gzip pre-compression to generate .gz files at build time. The plug serves them automatically when the client supports it:

# config/config.exs
config :phoenix_prerender,
  compressors: [PhoenixPrerender.Compressor.Gzip]

For Brotli, implement the PhoenixPrerender.Compressor behaviour (see Pre-Compression) and add your module to the list. The plug prefers br over gzip when both are available.

Multi-node cluster

For distributed deployments, add PubSub so cache invalidations propagate across nodes:

# config/prod.exs
config :phoenix_prerender,
  enabled: true,
  isr: true,
  revalidate: 300,
  prewarm: true,
  pubsub: MyApp.PubSub,
  base_url: "https://myapp.com",
  compressors: [PhoenixPrerender.Compressor.Gzip]

See Distributed Regeneration for supervision tree setup.

Development

In development, prerendering is disabled by default (enabled: false). You can still generate pages for testing:

mix phoenix.prerender --path /about

The static asset path helper gracefully returns the original path when the endpoint has no static manifest configured, so templates work without changes between dev and prod.

Per-environment overrides

Some settings make sense to vary by environment:

Setting Dev Test Prod
enabledfalsefalsetrue
prewarmfalsefalsetrue
compressors[][][Compressor.Gzip]
isrfalsefalsetrue / false
output_path default test-specific default

How Generation Works

When you run mix phoenix.prerender, the generator:

  1. Discovers routes — calls Phoenix.Router.routes/1 and filters for routes with metadata: %{prerender: true}
  2. Renders each route — builds a Plug.Conn, sets accept: text/html, and dispatches through the full endpoint pipeline using Phoenix.ConnTest.dispatch/5
  3. Writes HTML to disk — uses atomic writes (write to .tmp, then rename) to prevent serving partially written files
  4. Generates manifest — writes manifest.json with checksums, file sizes, and timestamps for each page
  5. Generates sitemap — writes sitemap.xml with absolute URLs for all generated pages

Generation is concurrent — pages are rendered in parallel using Task.async_stream with configurable concurrency.

URL Styles

Two styles control how URL paths map to files on disk:

Style URL Path File Path
:dir_index (default) /aboutabout/index.html
:dir_index/index.html
:file/aboutabout.html
:file/index.html

The Serving Plug

PhoenixPrerender.Plug runs in your endpoint before the router. For each request it:

  1. Checks if prerendering is enabled (skips if disabled)
  2. Normalizes the request path (strips trailing slashes)
  3. Validates path safety (rejects directory traversal attempts)
  4. Looks up the expected file path on disk
  5. If the file exists, serves it with send_file and halts
  6. If not, passes through to the rest of the pipeline

Served responses include these headers:

You can override options per-plug:

plug PhoenixPrerender.Plug,
  output_path: "priv/static/prerendered",
  url_style: :dir_index,
  cache_control: "public, max-age=3600",
  enabled: true

Incremental Static Regeneration (ISR)

ISR keeps prerendered pages fresh without full rebuilds. Enable it with:

# config/prod.exs
config :phoenix_prerender,
  enabled: true,
  isr: true,
  revalidate: 300  # seconds

Add the required processes to your supervision tree:

# lib/my_app/application.ex
children = [
  MyAppWeb.Endpoint,
  PhoenixPrerender.PageCache,
  {PhoenixPrerender.Regenerator, endpoint: MyAppWeb.Endpoint}
]

How ISR works:

  1. A request comes in for /about
  2. The plug finds about/index.html on disk and serves it immediately
  3. If the file is older than revalidate seconds, a background task is spawned
  4. The background task re-renders the page and writes the fresh HTML to disk
  5. An ETS lock prevents multiple processes from regenerating the same page
  6. The next request gets the fresh content

This is the stale-while-revalidate pattern — users always get an instant response, and the content converges to fresh.

Distributed Regeneration

When running on multiple BEAM nodes, use PhoenixPrerender.Cluster for cluster-wide coordination:

# config/prod.exs
config :phoenix_prerender,
  pubsub: MyApp.PubSub

PhoenixPrerender.Cluster.regenerate/4 uses :global.trans/2 to ensure only one node regenerates a given page, then broadcasts via PubSub so all nodes invalidate their local caches.

Subscribe in a GenServer to react to cross-node regenerations:

def init(state) do
  PhoenixPrerender.Cluster.subscribe()
  {:ok, state}
end

def handle_info({:regenerated, path}, state) do
  PhoenixPrerender.Cluster.handle_regenerated(path)
  {:noreply, state}
end

Page Cache

PhoenixPrerender.PageCache is an optional ETS-based in-memory cache that stores rendered HTML for even faster serving (avoiding disk reads):

# Add to supervision tree
children = [
  PhoenixPrerender.PageCache
]

The cache supports:

Cache Prewarming

By default, the first request for each page incurs a disk read. Cache prewarming loads all pages from the manifest into ETS on boot, so every request is served from memory from the start.

# config/prod.exs
config :phoenix_prerender,
  prewarm: true
# lib/my_app/application.ex
children = [
  MyAppWeb.Endpoint,
  {PhoenixPrerender.PageCache, prewarm: true}
]

Prewarming uses handle_continue, so the supervisor does not block — the application starts immediately, and the ETS table is populated asynchronously. Requests that arrive before prewarming completes get a cache miss and fall back to disk, so there is no downtime window.

A [:phoenix_prerender, :prewarm] telemetry event is emitted with %{duration: native_time, count: pages_loaded} when prewarming completes.

Missing files are warned and skipped — a partially present output directory does not crash the application.

Static Asset Path Helper

When prerendering at build time, templates may reference undigested asset paths. PhoenixPrerender.StaticAsset.static_path/2 resolves /assets/app.css/assets/app-ABC123.css using the endpoint's static manifest:

PhoenixPrerender.static_asset_path(MyAppWeb.Endpoint, "/assets/app.css")
#=> "/assets/app-ABC123.css"

During mix phoenix.prerender, the endpoint is started (the task calls app.start), so this resolution works automatically. In dev mode or when the manifest is not configured, the original path is returned unchanged as a graceful fallback.

Note: Phoenix's own Endpoint.static_path/1 already works in templates during prerendering. This helper provides a standalone function for programmatic use and edge cases outside of templates.

Pre-Compression

Pre-compression generates compressed variants of HTML files at build time (e.g., about/index.html.gz), so the serving plug can send them directly without on-the-fly compression overhead.

Enabling gzip pre-compression

# config/config.exs
config :phoenix_prerender,
  compressors: [PhoenixPrerender.Compressor.Gzip]

This uses Erlang's built-in :zlib module — no NIF dependencies required. After generation, each page will have a .gz sibling:

priv/static/prerendered/
├── about/
│   ├── index.html
│   └── index.html.gz

Adding Brotli (or any custom compressor)

Compression is pluggable via the PhoenixPrerender.Compressor behaviour. Implement compress/1 and extension/0:

defmodule MyApp.BrotliCompressor do
  @behaviour PhoenixPrerender.Compressor

  @impl true
  def compress(content) do
    case ExBrotli.compress(content) do
      {:ok, compressed} -> {:ok, compressed}
      error -> {:error, error}
    end
  end

  @impl true
  def extension, do: ".br"
end
config :phoenix_prerender,
  compressors: [PhoenixPrerender.Compressor.Gzip, MyApp.BrotliCompressor]

How serving works

When PhoenixPrerender.Plug serves a page from disk, it negotiates encoding:

  1. Parses the accept-encoding request header
  2. Checks for compressed files in preference order: br > gzip > identity
  3. If a compressed file exists, serves it with content-encoding and vary: accept-encoding headers
  4. If no compressed file exists, serves the uncompressed original

Cache-served responses (from ETS) are sent as-is without pre-compression headers. If your endpoint or web server (Cowboy/Bandit) is configured for response compression, it will compress these responses on the fly. Otherwise, they are served uncompressed.

Compressors are fault-tolerant: if a compressor fails, it logs a warning and is skipped. The uncompressed file is always written regardless.

Mix Task

The mix phoenix.prerender (or mix phx.prerender) task provides CLI access to generation:

# Generate all prerender-marked routes
mix phx.prerender

# Specify router and endpoint explicitly
mix phx.prerender --router MyAppWeb.Router --endpoint MyAppWeb.Endpoint

# Regenerate only specific pages (can be repeated)
mix phx.prerender --path /about --path /docs/terms

# Use file-style URLs (about.html instead of about/index.html)
mix phx.prerender --style file

# Custom output directory
mix phx.prerender --output _build/prerendered

# Limit concurrency (useful on memory-constrained CI runners)
mix phx.prerender --concurrency 2

Important: The --output and --style flags only affect where and how the task writes files. The serving plug must be configured to match, otherwise it will look in the wrong location or for the wrong filenames. Set these via application config so both the task and plug stay in sync:

config :phoenix_prerender,
  output_path: "_build/prerendered",
  url_style: :file

Telemetry

PhoenixPrerender emits telemetry events at key lifecycle points:

Event When Measurements Metadata
[:phoenix_prerender, :generate] After full generation run duration, count, successes, failuresoutput_path
[:phoenix_prerender, :render] After rendering a single page durationpath, status
[:phoenix_prerender, :serve] When serving a prerendered page durationpath, source
[:phoenix_prerender, :regenerate] After ISR regeneration durationpath, result
[:phoenix_prerender, :prewarm] After cache prewarming on boot duration, countoutput_path

All durations are in native time units. Use with Telemetry.Metrics:

def metrics do
  [
    summary("phoenix_prerender.generate.duration", unit: {:native, :millisecond}),
    counter("phoenix_prerender.serve.duration"),
    summary("phoenix_prerender.render.duration", unit: {:native, :millisecond}, tags: [:status])
  ]
end

Or attach the built-in debug logger:

PhoenixPrerender.Telemetry.attach_default_logger()

Manifest & Sitemap

After generation, two files are written to the output directory:

manifest.json — metadata for every generated page:

{
  "generated_at": "2024-01-15T10:30:00Z",
  "pages": [
    {
      "route": "/about",
      "file": "priv/static/prerendered/about/index.html",
      "size": 4521,
      "checksum": "a1b2c3d4...",
      "generated_at": "2024-01-15T10:30:00Z"
    }
  ]
}

sitemap.xml — standard sitemaps.org format:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://example.com/about</loc>
    <lastmod>2024-01-15T10:30:00Z</lastmod>
  </url>
</urlset>

Read the manifest programmatically:

{:ok, manifest} = PhoenixPrerender.Manifest.read("priv/static/prerendered")
page = PhoenixPrerender.Manifest.lookup(manifest, "/about")
page["checksum"]

CI Integration

Add prerendering to your deployment pipeline:

# .github/workflows/deploy.yml
- name: Generate prerendered pages
  run: |
    mix deps.get
    mix compile
    mix phoenix.prerender
    mix phx.digest

LiveView Compatibility

LiveView routes work seamlessly with prerendering. The generator renders the HTTP (non-WebSocket) path, producing static HTML that includes data-phx-session and data-phx-static attributes. When the browser loads the prerendered page and connects via WebSocket, LiveView hydrates normally.

prerender do
  live "/changelog", ChangelogLive
end

Performance

Benchmarking the same /about page (10.6 KB) served three ways on Apple M1 Max:

Serving Mode Throughput Avg Latency Memory vs Cache
Prerendered (ETS cache) 5,000 req/s 200 μs 5.4 KB
Prerendered (disk) 4,040 req/s 248 μs 8.3 KB 1.24x slower
Dynamic (full Phoenix pipeline) 45 req/s 22,134 μs 46.0 KB 110x slower

Prerendered pages serve ~110x faster with ~8.5x less memory than rendering dynamically through the full Phoenix pipeline (router, controller, template). The ETS cache and disk paths perform similarly thanks to OS-level file caching.

Run the benchmark yourself:

cd demo && mix run bench/plug_serving_bench.exs

Module Reference

Module Purpose
PhoenixPrerender Main module, configuration, prerender/1 macro
PhoenixPrerender.Plug Serves prerendered files from disk with encoding negotiation
PhoenixPrerender.Generator Concurrent page generation with atomic writes and pre-compression
PhoenixPrerender.Renderer Renders routes through the endpoint pipeline
PhoenixPrerender.StaticAsset Resolves digested asset paths via endpoint
PhoenixPrerender.Compressor Behaviour and orchestrator for pluggable pre-compression
PhoenixPrerender.Compressor.Gzip Built-in gzip compressor using :zlib (no NIF)
PhoenixPrerender.Route Discovers prerender-marked routes
PhoenixPrerender.Path URL-to-filesystem path mapping
PhoenixPrerender.Manifest Manifest and sitemap read/write
PhoenixPrerender.PageCache ETS-based in-memory page cache with optional prewarming
PhoenixPrerender.Regenerator ISR with ETS-based lock management
PhoenixPrerender.Cluster Distributed regeneration via :global and PubSub
PhoenixPrerender.Telemetry Telemetry event definitions and default logger
Mix.Tasks.Phoenix.Prerender Mix task for CLI generation

Roadmap

v0.2.0 — Optimizations & Developer Experience ✅

v0.3.0 — Distributed Consistency & Cache Control

v0.4.0 — Enhanced Hybrid DX

v0.5.0 — Performance & Edge Optimization

v0.6.0 — Persistence & Distribution

v0.7.0 — Workflow Automation & Orchestration

License

MIT