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 .md file, 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.exs and test suite
  • When ready to open-source or publish to Hex, swap path: "../blogex" for a version number

License

MIT