PhoenixPrerender - static prerendering and incremental static regeneration for Phoenix

Hi Elixir Drinkers!

I just published PhoenixPrerender, a library that brings static site generation and incremental static regeneration (ISR) to Phoenix — think Next.js ISR or SvelteKit prerendering, but built entirely on the BEAM.

I hope it’s of use. I been using it in production on several sites I thought I package it let it out into the big bad world.

The problem

Phoenix is great at rendering HTML, but for pages that rarely change (marketing pages, docs, changelogs), you’re paying the full rendering cost on every request — router matching, controller dispatch, template compilation, layout assembly. That adds up at scale.

What PhoenixPrerender does

  1. Build time: mix phoenix.prerender renders your marked routes through the full Phoenix endpoint pipeline and writes static HTML files to disk
  2. Runtime: A plug intercepts requests and serves those files directly — ~200μs instead of ~22ms (110x faster)
  3. ISR: When pages go stale, the next request serves the existing content immediately while triggering a background regeneration. Users never wait.

Setup takes 5 minutes

# 1. Add the dep
{:phoenix_prerender, "~> 0.1.0"}

# 2. Mark routes
import PhoenixPrerender

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

# 3. Add the plug (before the router)
plug PhoenixPrerender.Plug

# 4. Configure
config :phoenix_prerender, enabled: true

# 5. Generate
# $ mix phoenix.prerender

That’s it. Everything else is opt-in.

Key features

  • Router macroprerender do ... end wraps routes cleanly, or use metadata: %{prerender: true} explicitly
  • Three-tier serving — ETS memory cache → disk → Phoenix fallback
  • ISR — stale-while-revalidate with configurable TTL, ETS-based locks prevent thundering herd
  • Distributed — cluster-wide locking via :global.trans/2, cache invalidation via Phoenix PubSub
  • LiveView compatible — prerender LiveView routes; the static HTML includes data-phx-session attributes so the browser hydrates normally
  • Atomic writes — write to .tmp then rename, so you never serve a partial file
  • Concurrent generation — parallel rendering with Task.async_stream
  • Manifest + sitemap — automatic manifest.json (with checksums) and sitemap.xml
  • Telemetry — events for generation, rendering, serving, and regeneration
  • Strict paths — only serve paths listed in the manifest (enabled by default)

Benchmarks

Same /about page (10.6 KB), Apple M1 Max:

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

110x faster, 8.5x less memory per request.

What this is NOT

  • Not a static site generator that replaces Phoenix — your app still runs normally
  • Not a CDN — but pairs well with one (the Cache-Control header is configurable)
  • Not needed for every page — contact forms, dashboards, personalized content should stay dynamic

Demo app

The repo includes a full demo app that showcases all four rendering modes side by side: prerendered controller pages, LiveView + prerender, ISR, and dynamic. You can run it locally with:

cd demo && mix deps.get && mix phx.server

Links

Feedback, issues, and PRs welcome. Would love to hear if this is useful for your projects.

12 Likes

This looks fantastic!
I’m going to try it out on my personal website (https://nuno.site) and will report back!

Reporting back.
Added it to my site as I said I would above.
Works wonders! Pages loaded fast but now they are lightning fast. :smiley:

Good job man, really nice library. This is such a good idea I could it see it being part of Phoenix itself.
I will report any problems I find.

Now I to add the mix command to prerender pages to before I release to keep them updated. :slight_smile:

PS - My server is the smalleste instance of a Hetzner VPS, so 2 vCPU / 4GB RAM.

1 Like