Blogex
A multi-blog engine for Phoenix apps, powered by NimblePublisher.
Host an engineering blog and release notes (or any number of blogs) from markdown files in your repo. Posts compile into the BEAM at build time — zero database, zero runtime I/O, sub-millisecond reads.
Features
- Multi-blog support — run separate blogs from one app (engineering, release notes, etc.)
- Markdown + frontmatter — write posts in your editor, version-control in git
- Compile-time indexing — NimblePublisher bakes posts into module attributes
- Tagging & categorization — filter posts by tag, per-blog or across all blogs
- RSS & Atom feeds — auto-generated feeds per blog
- SEO helpers — meta tags, OpenGraph, sitemaps
- Phoenix components — unstyled function components you wrap in your layout
- Mountable router — forward routes to Blogex, it handles the rest
- Live reload — edit a
.mdfile, see it instantly in dev
Repository layout
Blogex is designed to live alongside your Phoenix app in a monorepo:
firehose/ ← git root
├── app/ ← your Phoenix SaaS (OTP app: :firehose)
│ ├── lib/
│ │ ├── firehose/
│ │ └── firehose_web/
│ ├── priv/
│ │ └── blog/ ← markdown posts live here
│ │ ├── engineering/
│ │ └── release-notes/
│ └── mix.exs
└── blogex/ ← this library
├── lib/
├── test/
└── mix.exs
Installation
In app/mix.exs, add blogex as a path dependency:
defp deps do
[
{:blogex, path: "../blogex"},
# Optional: syntax highlighting for code blocks
{:makeup_elixir, ">= 0.0.0"},
{:makeup_erlang, ">= 0.0.0"}
]
end
Setup
1. Create your posts directory
Inside your Phoenix app:
app/priv/blog/
├── engineering/
│ └── 2026/
│ ├── 03-10-our-new-architecture.md
│ └── 02-15-scaling-postgres.md
└── release-notes/
└── 2026/
└── 03-01-v2-launch.md
2. Write posts with frontmatter
%{
title: "Our New Architecture",
author: "Jane Doe",
tags: ~w(elixir otp architecture),
description: "How we rebuilt our platform on OTP"
}
---
## The problem
Our monolith was getting unwieldy...
## The solution
We broke it into an umbrella of focused OTP apps...
The filename encodes the date: YYYY/MM-DD-slug.md
3. Define blog modules
# lib/firehose/blogs/engineering_blog.ex
defmodule Firehose.EngineeringBlog do
use Blogex.Blog,
blog_id: :engineering,
app: :firehose,
from: "priv/blog/engineering/**/*.md",
title: "Engineering Blog",
description: "Deep dives into our tech stack",
base_path: "/blog/engineering"
end
# lib/firehose/blogs/release_notes.ex
defmodule Firehose.ReleaseNotes do
use Blogex.Blog,
blog_id: :release_notes,
app: :firehose,
from: "priv/blog/release-notes/**/*.md",
title: "Release Notes",
description: "What's new in Firehose",
base_path: "/blog/releases"
end
4. Configure
# config/config.exs
config :blogex,
blogs: [Firehose.EngineeringBlog, Firehose.ReleaseNotes]
5. Mount routes
# lib/firehose_web/router.ex
scope "/blog" do
pipe_through :browser
forward "/engineering", Blogex.Router, blog: Firehose.EngineeringBlog
forward "/releases", Blogex.Router, blog: Firehose.ReleaseNotes
end
6. Enable live reload (dev only)
# config/dev.exs
live_reload: [
patterns: [
...,
~r"priv/blog/*/.*(md)$"
]
]
Usage
Querying a single blog
Firehose.EngineeringBlog.all_posts()
Firehose.EngineeringBlog.recent_posts(5)
Firehose.EngineeringBlog.get_post!("our-new-architecture")
Firehose.EngineeringBlog.posts_by_tag("elixir")
Firehose.EngineeringBlog.all_tags()
Firehose.EngineeringBlog.paginate(1, 10)
Querying across all blogs
Blogex.all_posts() # all posts from all blogs, newest first
Blogex.all_tags() # all unique tags across blogs
Blogex.get_blog!(:engineering) # get the blog module
Using Phoenix components
import Blogex.Components
<.post_index posts={@posts} base_path="/blog/engineering" />
<.post_show post={@post} />
<.tag_list tags={@tags} base_path="/blog/engineering" current_tag={@tag} />
<.pagination page={@page} total_pages={@total_pages} base_path="/blog/engineering" />
Generating feeds
The mounted router serves feeds automatically at /feed.xml and /atom.xml.
You can also generate them manually:
Blogex.Feed.rss(Firehose.EngineeringBlog, "https://firehose.dev")
Blogex.Feed.atom(Firehose.EngineeringBlog, "https://firehose.dev")
SEO
# Meta tags for a post
meta = Blogex.SEO.meta_tags(post, "https://firehose.dev", Firehose.EngineeringBlog)
# Sitemap across all blogs
xml = Blogex.SEO.sitemap(Blogex.blogs(), "https://firehose.dev")
Architecture
Blogex follows the poncho pattern — it wraps NimblePublisher and presents a clean API to your Phoenix app. There are no GenServers or processes; all post data is compiled into BEAM bytecode via module attributes. This means:
- Reads are instant (no I/O, no database)
- Posts update on recompilation (or live reload in dev)
- The host app owns the layout, styling, and routing
- Blogex is a sibling library, not an umbrella child — it has its own
mix.exsand test suite - When ready to open-source or publish to Hex, swap
path: "../blogex"for a version number
License
MIT