There is a specific kind of irony in building an RSS reader that does not publish an RSS feed for its own blog. I noticed this about six weeks into shipping SlashFeed and it bothered me enough to fix it on a Sunday afternoon. But before I could fix the feed, I needed a blog — and the question of how to run a blog inside a Phoenix app without adding operational complexity led me straight to NimblePublisher.
The constraint I cared about: no additional infrastructure. No separate CMS, no headless WordPress, no database table for posts. The app already has PostgreSQL and RabbitMQ and Oban queues. I did not want to deploy a separate service, run a migration every time I wanted a new field, or pay the cold-start penalty of a database query to render a blog post to a bot crawler. Posts are text. Text can live in the repo.
NimblePublisher in one paragraph
NimblePublisher, maintained by Dashbit, does one thing: it reads a collection of Markdown files at compile time, parses their front-matter, converts the body to HTML using Earmark, optionally highlights code blocks with Makeup, and stores the resulting structs in a module attribute. After that, the module attribute is just a list — fast to iterate, easy to filter, no database round-trip required. Every post is compiled into the BEAM module. When you add a new post file, the module is recompiled.
The tradeoff is upfront: this is compile-time work. In development, adding a new post triggers a recompile of the blog context module, which takes a second or two. In production, posts are baked into the release — mix release runs the compilation, and the deployed binary already contains all posts as data. Hot-reloading a post in production means redeploying. That is a reasonable constraint for an engineering blog that publishes maybe twice a month.
Directory structure
Posts live under priv/posts/, organised by year, with a filename that encodes the publication date and the post slug:
priv/posts/
└── 2026/
├── 04-15-why-slash-feed-rss.md
├── 04-17-routing-elixir-http-traffic-through-tor-with-privoxy-and-finch.md
├── 05-21-mcp-oauth-for-your-rss-reader.md
└── 06-28-building-a-blog-in-phoenix-with-nimble-publisher.md
The naming convention MM-DD-slug.md is deliberate. Alphabetical order within a year directory is chronological order — the OS, File.ls/1, and Path.wildcard/1 all agree without any secondary sort key. The year is a directory rather than part of the filename because once a year has enough posts, a flat list becomes unwieldy. Parsing year/MM-DD-slug.md into a Date struct takes three lines.
The post file format
Each file starts with an Elixir map as front-matter, terminated by ---. No YAML, no TOML — just Elixir, which means the keys are atoms, the values are Elixir terms, and ~w(elixir phoenix) works for tags:
%{
title: "Building a blog in Phoenix with NimblePublisher",
author: "Luca",
tags: ~w(elixir phoenix nimble_publisher blog),
description: "How SlashFeed's engineering blog is built with NimblePublisher...",
published: true,
cover_image: "/images/blog/cover-nimble-publisher.svg"
}
---
The published field is intentional. Drafts live in the repo with published: false. I can push a work-in-progress post, iterate on it in production builds, and it will never appear in the public listing until I flip the flag. There is no staging environment for blog posts — the repo is staging.
The Post.build/3 callback is what NimblePublisher calls for each file. It receives the filename, the parsed front-matter map, and the rendered HTML body:
defmodule BoringRSS.Blog.Post do
@enforce_keys [:id, :author, :title, :body, :description, :tags, :date, :cover_image]
defstruct [:id, :author, :title, :body, :description, :tags, :date, :cover_image, :published]
def build(filename, attrs, body) do
[year, month_day_id] = filename |> Path.rootname() |> Path.split() |> Enum.take(-2)
[month, day, id] = String.split(month_day_id, "-", parts: 3)
date = Date.from_iso8601!("#{year}-#{month}-#{day}")
struct!(__MODULE__, [id: id, date: date, body: body] ++ Map.to_list(attrs))
end
end
Enum.take(-2) grabs the last two path segments — the year directory and the filename stem — regardless of where the post directory lives on disk or what Application.app_dir/2 resolves to. String.split(month_day_id, "-", parts: 3) splits into at most three pieces, so a slug like building-a-blog-in-phoenix-with-nimble-publisher is correctly preserved as the third element rather than being further split on its own hyphens.
The Blog context
The Blog context use NimblePublisher and immediately gets an @posts module attribute populated at compile time:
defmodule BoringRSS.Blog do
use NimblePublisher,
build: BoringRSS.Blog.Post,
from: Application.app_dir(:boring_rss, "priv/posts/**/*.md"),
as: :posts,
highlighters: [:makeup_elixir, :makeup_erlang]
@posts Enum.sort_by(@posts, & &1.date, {:desc, Date})
@published Enum.filter(@posts, & &1.published)
@tags @published |> Enum.flat_map(& &1.tags) |> Enum.uniq() |> Enum.sort()
def all_posts, do: @posts
def published_posts, do: @published
def recent_posts(n \\ 5), do: Enum.take(@published, n)
def all_tags, do: @tags
def get_posts_by_tag!(tag) do
case Enum.filter(@published, &(tag in &1.tags)) do
[] -> raise BoringRSS.Blog.NotFoundError, "tag #{tag} not found"
posts -> posts
end
end
def get_post!(id) do
Enum.find(@published, &(&1.id == id)) ||
raise BoringRSS.Blog.NotFoundError, "post #{id} not found"
end
end
A few things worth noting here. The @posts sort happens at module load time, not at request time — it runs once when the BEAM loads the module, and after that published_posts/0 returns a pre-sorted list. Same for @tags: the deduplication and sort is compile-time work, so calling all_tags/0 in a template is just a list lookup.
get_post!/1 raises BoringRSS.Blog.NotFoundError rather than returning {:error, :not_found}. This is idiomatic Phoenix: the router has an error handler that catches NotFoundError and renders a 404 page, so the controller does not need a case branch for the missing-post scenario. The convention is consistent with how Ecto's Repo.get!/2 works — the bang variant raises, callers handle the happy path.
The controller
The blog uses a plain Phoenix controller, not LiveView. There is no live interactivity on the blog — no real-time updates, no form submissions, no PubSub. LiveView adds overhead (a WebSocket connection, server-side process per visitor) that is pointless for a read-only page. The controller renders a show.html.heex template once per request and that is the end of it.
defmodule BoringRSSWeb.BlogController do
use BoringRSSWeb, :controller
alias BoringRSS.Blog
def index(conn, params) do
tag = params["tag"]
posts = if tag, do: Blog.get_posts_by_tag!(tag), else: Blog.published_posts()
render(conn, :index, posts: posts, tag: tag, tags: Blog.all_tags())
end
def show(conn, %{"id" => id}) do
post = Blog.get_post!(id)
BoringRSS.Blog.Analytics.track_view_async(post.id, conn)
render(conn, :show, post: post)
end
end
The index action supports tag filtering via a ?tag=elixir query param. get_posts_by_tag!/1 raises if the tag doesn't exist in the published set — which means a 404 for typos and dead links. That is intentional: it keeps the tag listing authoritative. If a tag appears in the URL, it must correspond to a real set of posts.
The blog's own RSS feed
This is the part I was most looking forward to. SlashFeed is an RSS reader. The blog publishes an RSS feed. You can subscribe to the blog inside SlashFeed and read new engineering posts alongside all your other feeds. The feed controller is eight lines:
defmodule BoringRSSWeb.BlogFeedController do
use BoringRSSWeb, :controller
def index(conn, _params) do
posts = BoringRSS.Blog.recent_posts(20)
conn
|> put_resp_content_type("application/rss+xml")
|> render(:index, posts: posts, layout: false)
end
end
The template is a plain .xml.eex file — not HEEx, because I'm generating XML rather than HTML and the HEEx HTML-awareness would get in the way. layout: false skips the application layout entirely; the response is raw XML with no surrounding <html> wrapper.
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>SlashFeed Blog</title>
<link>https://slashfeed.io/blog</link>
<description>Engineering notes on building a self-hosted RSS reader</description>
<lastBuildDate><%= DateTime.utc_now() |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") %></lastBuildDate>
<%= for post <- @posts do %>
<item>
<title><%= post.title %></title>
<link>https://slashfeed.io/blog/<%= post.id %></link>
<description><%= post.description %></description>
<pubDate><%= post.date |> DateTime.new!(~T[12:00:00], "Etc/UTC") |> Calendar.strftime("%a, %d %b %Y %H:%M:%S GMT") %></pubDate>
<guid>https://slashfeed.io/blog/<%= post.id %></guid>
</item>
<% end %>
</channel>
</rss>
The pubDate conversion from Date to a UTC DateTime anchors each post at noon UTC. This is arbitrary — NimblePublisher gives us a Date, not a DateTime, and RSS requires an RFC 822 datetime string. Noon avoids any timezone boundary confusion where a post dated 2026-06-28 could appear as June 27 to a reader in a UTC-5 timezone if we anchored at midnight. Noon is universally safe.
Syntax highlighting
NimblePublisher uses Earmark for Markdown and optionally delegates fenced code blocks to Makeup highlighters. Enabling Elixir and Erlang highlighting is one line in the use call:
highlighters: [:makeup_elixir, :makeup_erlang]
Makeup generates a CSS class per token type and the actual colours live in a stylesheet. There is no inline style attribute nonsense, which means you can swap themes by swapping a CSS file. Generate the default stylesheet once:
mix makeup.gen.css
This writes assets/css/makeup.css. I import it at the top of the application CSS and then write custom overrides for the .blog-prose wrapper to match the dark theme:
@import "./makeup.css";
.blog-prose pre {
border-radius: 0.75rem;
overflow-x: auto;
background: #1e1e2e;
padding: 1.2rem 1.4rem;
}
.blog-prose code:not(pre code) {
background: rgba(99, 102, 241, 0.12);
color: #a5b4fc;
padding: 0.15em 0.4em;
border-radius: 0.3em;
font-size: 0.875em;
}
The :not(pre code) selector is important. Without it, the inline code style would also apply to code inside fenced blocks, double-wrapping the background colour. The selector targets only bare `backtick` spans in prose, leaving Makeup's highlighted blocks untouched.
The code block language identifiers in these posts use a fake path format — ```lib/boring_rss/blog.ex#L1-10 — to show readers where in the codebase the snippet lives. Earmark treats the entire string as the language hint; Makeup ignores unknown languages and renders the block unstyled, which is fine. The path annotation is for the human reader, not the highlighter.
View analytics without cookies
I wanted to know which posts were getting read without installing Google Analytics, Plausible, or any third-party script. The requirements: no cookies, no cross-site tracking, no session state, just a rough view count per post per day.
The solution is a BlogView Ecto schema with three fields — post_id, ip_hash, and referrer_host — and a deduplication window:
defmodule BoringRSS.Blog.Analytics do
def track_view_async(post_id, conn) do
Task.start(fn -> track_view(post_id, conn) end)
end
defp track_view(post_id, conn) do
ip_hash = conn |> get_client_ip() |> hash()
unless already_counted?(post_id, ip_hash) || bot_request?(conn) do
%BlogView{}
|> BlogView.changeset(%{
post_id: post_id,
ip_hash: ip_hash,
referrer_host: extract_referrer_host(conn)
})
|> Repo.insert()
end
end
defp already_counted?(post_id, ip_hash) do
cutoff = DateTime.add(DateTime.utc_now(), -24 * 3600)
Repo.exists?(
from v in BlogView,
where: v.post_id == ^post_id and v.ip_hash == ^ip_hash and v.inserted_at > ^cutoff
)
end
defp bot_request?(conn) do
ua = get_req_header(conn, "user-agent") |> List.first() || ""
String.match?(ua, ~r/bot|crawler|spider|slurp|googlebot|bingbot/i)
end
defp hash(value), do: :crypto.hash(:sha256, value) |> Base.encode16(case: :lower)
end
Task.start/1 fires and forgets — it does not block the request. The controller renders the post immediately; the analytics write happens in a separate process. If the database is slow or down, the request is unaffected.
The IP address is never stored. We hash it with SHA-256 before it touches the database, and we never log or persist the raw value. The hash is only ever compared against itself within the 24-hour deduplication window; after that window expires it stops being consulted (though the row stays for aggregate counts). Someone who reads the same post twice in 24 hours from the same IP contributes one view. Someone who reads it a week later contributes a second. That is close enough to a meaningful view count without any of the legal or privacy overhead of storing IPs.
Bot filtering is a simple user-agent regex. It won't catch every crawler, but it eliminates the majority of automated traffic that would otherwise inflate counts.
Live reload in development
Out of the box, Phoenix's live reload watches lib/ for code changes and priv/static/ for asset changes. It does not watch priv/posts/ — new or edited Markdown files don't trigger a page reload unless you add the pattern:
config :boring_rss, BoringRSSWeb.Endpoint,
live_reload: [
patterns: [
~r"priv/posts/.*(md)$",
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
~r"lib/boring_rss_web/(controllers|live|components)/.*(ex)$"
]
]
One thing to understand about this setup: when you save a Markdown file, Phoenix detects the change and triggers a recompile of the modules that depend on priv/posts/. NimblePublisher uses @external_resource to track each post file as a dependency of the blog context module. This means the BoringRSS.Blog module is recompiled, the new or updated post is parsed, and the module attribute is updated — all without restarting the server. The browser tab reloads, and you see the updated post. It is a good enough development loop for content editing.
The compile-time caveat worth knowing: if you add a field to the Post struct's @enforce_keys list, you'll need to update all existing post files to include that field, or the compilation will fail. This is NimblePublisher doing its job — struct!/2 with @enforce_keys catches missing front-matter at compile time rather than at runtime, which is exactly the kind of early feedback you want when writing content.
What this stack does not include
No search. The in-memory list approach does not lend itself to full-text search — you'd need to load all post bodies and run String.contains?/2 over them, which is fine for a dozen posts and untenable for a hundred. When the need arises, Postgres full-text search on a posts table is the right next step. For now, the tag filter covers the main navigation use case, and search engines do the rest.
No comments. This is a deliberate absence. Comment sections on technical blogs tend to accumulate either spam or heated arguments faster than they accumulate useful discussion. The post author's email is in the footer; that is enough.
The full setup — NimblePublisher in the dependency list, the Post struct, the Blog context, the two controllers, and the RSS template — is under two hundred lines of Elixir. The operational cost is zero: the blog is part of the application binary, it scales with the application, and it is backed up automatically with every database snapshot. For a blog that publishes intermittently, that is the right tradeoff.