February 14, 2026 · Variant Systems
PhoenixPress: Compile-Time SEO for Phoenix Apps
We open-sourced PhoenixPress — sitemaps, robots.txt, and RSS feeds for Phoenix, generated at compile time with zero runtime overhead.

As technical founders building products, we want to never leave the Elixir ecosystem. But some edges are rougher than others. Press infrastructure — sitemaps, RSS feeds, robots.txt — is one of them.
The go-to sitemap library for Elixir? Last commit was eight years ago. No RSS feed support. And RSS feeds aren’t optional anymore — they’re how AI agents discover your content. Microsoft’s NLWeb uses RSS feeds as a primary knowledge source. Andrej Karpathy is publicly advocating a return to RSS as a filter against AI-generated slop. And then there are newer standards like llms.txt that didn’t exist when that library was last maintained.
We hit all of this while trying to stay in Elixir — building and marketing a product written entirely in Phoenix. It stings more when you know frameworks like Astro handle sitemaps, feeds, and structured metadata with zero thinking.
We considered migrating. Took a deep breath. The anxiety settled. The answer was obvious: build the tooling we needed, in the ecosystem we chose. So we built phoenix_press and open-sourced it.
Why Compile-Time, Not Runtime
Most sitemap libraries work the same way: a request hits /sitemap.xml, your app queries the database, builds XML, and returns it. Every single time.
Your sitemap doesn’t change between deployments. Your robots.txt definitely doesn’t change between requests. Runtime generation is burning CPU and database queries on content that’s static by nature.
Some teams add caching layers. Now you have cache invalidation to think about. Others drop files in priv/static and forget about them — then add ten new pages and wonder why Google isn’t indexing them.
There’s a simpler way.
What We Built
PhoenixPress uses Elixir macros to generate sitemaps, robots.txt, and RSS feeds at compile time. The output is a static string baked into your module. When a request comes in, your app serves that string. No database queries. No XML building. No template rendering. Just a pre-built response.
Add it to your deps:
def deps do
[
{:phoenix_press, "~> 0.1.0"}
]
endThen tell it what your site looks like.
Sitemaps
We wanted sitemaps to read like a description of your site, not a config file. Here’s what that looks like:
defmodule MyAppWeb.Press.Sitemap do
use PhoenixPress.Sitemap, base_url: "https://example.com"
add "/", changefreq: :weekly, priority: 1.0
add "/blog", changefreq: :weekly, priority: 0.8
add "/about", changefreq: :monthly, priority: 0.6
endEach add call registers a URL. At compile time, PhoenixPress turns these into a complete XML sitemap. You’re looking at your sitemap right there in the module — no separate file to forget about.
Got blog posts or other dynamic content? Use a comprehension:
defmodule MyAppWeb.Press.Sitemap do
use PhoenixPress.Sitemap, base_url: "https://example.com"
add "/", changefreq: :weekly, priority: 1.0
add "/blog", changefreq: :weekly, priority: 0.8
for post <- MyApp.Blog.all_posts() do
add "/blog/#{post.id}", changefreq: :monthly, lastmod: post.date
end
endThat for runs at compile time. It pulls your posts during compilation and bakes them into the sitemap. New post? Recompile and you’re done — which you’re doing on every deploy anyway.
Robots.txt
Same idea:
defmodule MyAppWeb.Press.Robots do
use PhoenixPress.Robots, base_url: "https://example.com"
sitemap "/sitemap.xml"
allow "/"
disallow "/dashboard"
endDeclarative. Readable. Version-controlled. No more editing a text file in priv/static and hoping you got the syntax right.
RSS Feeds
This was the one we really wanted. Every content-heavy Phoenix app needs a feed, and there was nothing good out there for it:
defmodule MyAppWeb.Press.Feed do
use PhoenixPress.Feed,
title: "My Blog",
description: "Latest posts",
base_url: "https://example.com"
for post <- MyApp.Blog.all_posts() do
entry post.title,
link: "/blog/#{post.id}",
description: post.description,
pub_date: post.date,
author: post.author
end
endSame pattern as sitemaps. The entry macro registers feed items, and the full RSS XML gets compiled into a static string. Your feed is always in sync with your content at deploy time.
Wiring It Up
One plug in your endpoint, before the router:
plug PhoenixPress.Plug,
sitemap: MyAppWeb.Press.Sitemap,
robots: MyAppWeb.Press.Robots,
feed: MyAppWeb.Press.FeedThat’s it. Three routes, live:
GET /sitemap.xml→Content-Type: text/xmlGET /robots.txt→Content-Type: text/plainGET /feed.xml→Content-Type: application/rss+xml
All responses ship with Cache-Control: public, max-age=3600 headers. Don’t need one of the three? Skip it — they’re all optional.
Under the Hood
For the Elixir-curious: here’s what’s actually happening.
When you use PhoenixPress.Sitemap, the macro sets up a module attribute to accumulate entries. Each add call appends to that attribute at compile time. Before the module finishes compiling (__before_compile__), we take all accumulated entries, generate the complete XML string, and store it as a module attribute.
At runtime, serving a request means returning that pre-built string. No computation. No allocation beyond the response itself.
If you’ve worked with Elixir internals, this pattern will look familiar — it’s the same approach Elixir uses for protocol consolidation. Macros accumulate data during compilation. A callback assembles the final result. The compiled module contains only the output.
The tradeoff is explicit: your sitemap reflects the state of your content at compile time. For most apps — especially anything with CI/CD — this is a non-issue. You’re recompiling on every deploy anyway.
Why This Matters Beyond Performance
Sure, for a marketing site getting 100 requests per day to /sitemap.xml, runtime generation is fine. For a high-traffic app where crawlers are hitting you aggressively, compile-time wins.
But honestly, the real win isn’t performance. It’s one less thing to think about.
No caching layer to configure. No background jobs to regenerate sitemaps. No stale cache bugs. No database dependency for serving SEO files. Your sitemap is compiled into your application the same way your routes are. It’s always correct for the current deployment. It’s always fast. There’s nothing to debug.
We wanted to stay in Elixir and not feel like we were making compromises on the marketing side of our product. PhoenixPress is how we got there.
Get Started
PhoenixPress is MIT-licensed and available now:
We run this on every Phoenix project we ship. If you’re building with Phoenix and tired of cobbling together SEO infrastructure, give it a try.
Building with Elixir and Phoenix? Variant Systems helps teams ship production applications with solid foundations.