Plug-and-play blog engine for Phoenix with Editor.js integration.
- Public blog with search, tag filtering, and pagination
- Admin dashboard with Editor.js rich text editor
- Auto-save drafts β posts save automatically as you type
- SEO out of the box β Open Graph, Twitter Cards, canonical URLs, and JSON-LD structured data
- Optional likes β database-backed, auth-required toggle with like counts
- Optional share buttons β Copy link, Twitter/X, Facebook, LinkedIn
- Embeddable recent posts component for any page
- Self-contained CSS and JS β loads its own assets automatically
- Supports PostgreSQL, MySQL, and SQLite
- Authentication via host app's
on_mounthooks
Add phoenix_blog to your list of dependencies in mix.exs:
def deps do
[
{:phoenix_blog, "~> 0.1"}
]
end# config/config.exs
config :phoenix_blog, repo: MyApp.Repomix ecto.gen.migration add_phoenix_blogdefmodule MyApp.Repo.Migrations.AddPhoenixBlog do
use Ecto.Migration
def up, do: PhoenixBlog.Migration.up()
def down, do: PhoenixBlog.Migration.down()
endmix ecto.migrateAdd to your endpoint.ex, before the existing Plug.Static:
plug Plug.Static,
at: "/phoenix_blog",
from: {:phoenix_blog, "priv/static"}In your assets/js/app.js:
import { PhoenixBlogHooks } from "../../deps/phoenix_blog/priv/static/editorjs/hook.js"
const liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: { ...PhoenixBlogHooks, ...yourOtherHooks }
})Editor.js scripts are loaded dynamically by the hook β no additional <script> tags needed.
Add the library's templates to your Tailwind source paths so its utility classes get generated. In assets/css/app.css:
@source "../../deps/phoenix_blog/lib";In your router.ex:
use PhoenixBlog.Web, :router
# Public blog (accessible to everyone)
scope "/" do
pipe_through :browser
phoenix_blog "/blog"
end
# Admin dashboard (protected by your auth)
scope "/" do
pipe_through [:browser, :require_authenticated_user]
phoenix_blog_dashboard "/admin/blog",
on_mount: [{MyAppWeb.UserAuth, :require_authenticated}]
endThat's it. The blog renders inside your app's root layout automatically.
Add these two lines to the <head> in your root layout (lib/my_app_web/components/layouts/root.html.heex):
<PhoenixBlog.Web.SEO.meta_tags seo={assigns[:seo]} />
<PhoenixBlog.Web.SEO.json_ld seo={assigns[:seo]} />These are no-ops on non-blog pages (when @seo is nil), so they're safe to include globally. On blog pages they automatically render Open Graph tags, Twitter Cards, canonical URLs, and JSON-LD structured data.
Alternatively, create a dedicated blog root layout (e.g. blog_root.html.heex) with the SEO tags included and pass it to the router:
phoenix_blog "/blog", root_layout: {MyAppWeb.Layouts, :blog_root}Every blog page ships with complete SEO metadata β zero configuration required.
Blog post pages (/blog/:slug):
<title>set to the post title<meta name="description">from the admin's SEO description field, or auto-generated from the first paragraph- Open Graph tags:
og:title,og:description,og:image,og:url,og:type(article),og:site_name,og:locale - Article tags:
article:published_time,article:modified_time,article:author,article:tag - Twitter Card tags:
twitter:card(summary_large_imagewhen an image exists),twitter:title,twitter:description,twitter:image <link rel="canonical">with the clean URL (no query params)- JSON-LD structured data (
Articleschema with headline, author, dates, publisher)
Blog index page (/blog):
- Open Graph tags with
og:typeset towebsite - Twitter Card, canonical URL, and JSON-LD (
Blogschema)
All optional β sensible defaults are used if omitted:
# config/config.exs
config :phoenix_blog,
site_name: "My Blog", # default: "Blog"
default_og_image: "https://example.com/og.png", # fallback when post has no featured image
twitter_site: "@myhandle", # Twitter/X site handle
locale: "en_US" # Open Graph localeBoth features are opt-in and disabled by default. Enable either or both in your config:
# config/config.exs
config :phoenix_blog,
likes_enabled: true,
share_enabled: trueLikes require a user identity. Since the library doesn't own your users table, you provide a function that extracts the current user from the LiveView socket:
# config/runtime.exs
config :phoenix_blog,
likes_enabled: true,
# config/runtime.exs, in config/config.exs this function wont work
get_current_user: fn socket ->
case socket.assigns[:current_scope] do
%{user: user} -> user
_ -> nil
end
endThe default get_current_user function returns socket.assigns[:current_user], which works if your app assigns the user directly. The example above works with Phoenix 1.8's mix phx.gen.auth which uses current_scope.
Create a second migration for the likes table:
mix ecto.gen.migration add_phoenix_blog_likesdefmodule MyApp.Repo.Migrations.AddPhoenixBlogLikes do
use Ecto.Migration
def up, do: PhoenixBlog.Migration.up(version: 2)
def down, do: PhoenixBlog.Migration.down(version: 2)
endmix ecto.migrateThe blog routes need an on_mount hook that assigns the current user to the socket. For example, with mix phx.gen.auth:
# lib/my_app_web/live/user_auth_hook.ex
defmodule MyAppWeb.UserAuthHook do
import Phoenix.Component
alias MyApp.Accounts
alias MyApp.Accounts.Scope
def on_mount(:maybe_assign_user, _params, session, socket) do
user =
case session do
%{"user_token" => token} ->
case Accounts.get_user_by_session_token(token) do
{user, _token_inserted_at} -> user
nil -> nil
end
_ -> nil
end
{:cont, assign(socket, :current_scope, Scope.for_user(user))}
end
endThen pass it to the blog routes:
scope "/" do
pipe_through :browser
phoenix_blog "/blog",
on_mount: [{MyAppWeb.UserAuthHook, :maybe_assign_user}]
endWhen a user is logged in, they can toggle likes on posts. Anonymous visitors see the like count but cannot interact.
When share_enabled: true, each blog post page shows social share buttons for Copy link, Twitter/X, Facebook, and LinkedIn. No database or authentication required.
The Copy link button uses the PhoenixBlogCopyLink hook which is included in PhoenixBlogHooks β no additional JS setup needed if you followed the hooks setup in step 4.
After setup, the built-in pages at /blog and /admin/blog are ready to use. You can also embed blog content into your own pages using the provided components.
Show the latest blog posts on any LiveView page:
<.live_component
module={PhoenixBlog.Web.Components.RecentPosts}
id="recent-posts"
blog_path="/blog"
/>| Attribute | Default | Description |
|---|---|---|
blog_path |
(required) | Path where your blog is mounted |
count |
3 |
Number of posts to display |
title |
"Latest Posts" |
Section heading (nil to hide) |
class |
nil |
Additional CSS classes on the wrapper |
Use the BlogFeed component to add a full blog listing with search, tag filters, and pagination to any page β for example, a homepage:
defmodule MyAppWeb.HomeLive do
use MyAppWeb, :live_view
alias PhoenixBlog.Web.Components.BlogFeed
def mount(_params, _session, socket), do: {:ok, socket}
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<h1>Welcome to my site</h1>
<.live_component
module={BlogFeed}
id="home-feed"
blog_path="/blog"
per_page={6}
show_hero={false}
/>
</Layouts.app>
"""
end
endPost cards link to /blog/:slug by default. The blog_path attribute must match the path you mounted phoenix_blog on in your router.
| Attribute | Default | Description |
|---|---|---|
blog_path |
(required) | Path where your blog is mounted |
per_page |
12 |
Items per page |
show_search |
true |
Show search bar |
show_tags |
true |
Show tag filter buttons |
show_hero |
false |
Show hero banner with title and post count |
class |
nil |
Additional CSS classes for the wrapper |
grid_class |
"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6" |
CSS classes for the posts grid |
Override the default card design using the :post_card slot. This is useful when your app uses a design system (e.g. daisyUI) and you want the blog feed to match:
<.live_component module={BlogFeed} id="home-feed" blog_path="/blog">
<:post_card :let={post}>
<a href={"/blog/#{post.slug}"} class="card bg-base-100 border hover:shadow-lg transition-all">
<figure :if={post.featured_image_url} class="h-48">
<img src={post.featured_image_url} alt={post.title} class="w-full h-full object-cover" />
</figure>
<div class="card-body p-5">
<h2 class="card-title text-lg">{post.title}</h2>
<p :if={post.author} class="text-sm opacity-50">{post.author}</p>
</div>
</a>
</:post_card>
<:empty>
<p class="text-center py-12">Nothing here yet!</p>
</:empty>
</.live_component>
The post struct passed to :let contains: title, slug, body, tags, author, featured_image_url, published_at, seo_description.
To build your own post page (e.g. with a sidebar or related posts), use the :show_view router option and the BlogPost component:
# router.ex
phoenix_blog "/blog", show_view: MyAppWeb.CustomBlogShowdefmodule MyAppWeb.CustomBlogShow do
use MyAppWeb, :live_view
import PhoenixBlog.Web.Components.BlogPost
def mount(_params, _session, socket), do: {:ok, socket}
def handle_params(%{"slug" => slug}, uri, socket) do
post = PhoenixBlog.get_post_by_slug!(slug)
{:noreply,
socket
|> assign(:post, post)
|> assign(:page_title, post.title)
|> PhoenixBlog.Web.SEO.assign_seo(post, uri)}
end
def render(assigns) do
~H"""
<div class="max-w-4xl mx-auto px-4 py-12">
<.blog_post post={@post} blog_path="/blog" show_tags_footer={false} />
<aside class="mt-12">
<.live_component
module={PhoenixBlog.Web.Components.RecentPosts}
id="related-posts"
blog_path="/blog"
title="More articles"
count={3}
/>
</aside>
</div>
"""
end
endCall PhoenixBlog.Web.SEO.assign_seo/3 in your handle_params to get full SEO metadata on custom views.
| Attribute | Default | Description |
|---|---|---|
post |
(required) | A %PhoenixBlog.Post{} struct |
blog_path |
"/blog" |
Path for back and tag links |
show_back_link |
true |
Show "Back to blog" link |
show_header |
true |
Show title, tags, author, and date |
show_featured_image |
true |
Show the featured image |
show_tags_footer |
true |
Show tags section at the bottom |
class |
nil |
CSS classes for the wrapper <article> element |
Use render_editor_blocks/1 to render post content anywhere:
import PhoenixBlog.Web.BlogComponents
<.render_editor_blocks blocks={Map.get(@post.body, "blocks", [])} />Supported block types: paragraph, header (h1-h6), list (ordered/unordered), quote (with caption), code, table, delimiter, embed (YouTube, Twitter, Vimeo), and image (with caption).
Extract a plain-text excerpt from any post's Editor.js body:
import PhoenixBlog.Web.BlogComponents
extract_excerpt(post) # 150 chars (default)
extract_excerpt(post, 100) # custom lengthReturns the first paragraph's text, stripped of HTML tags and truncated with ....
| Option | Default | Description |
|---|---|---|
:repo |
(required) | Your Ecto repo module |
:table_name |
"phoenix_blog_posts" |
Database table name |
:likes_enabled |
false |
Enable post likes |
:share_enabled |
false |
Enable social share buttons |
:get_current_user |
fn socket -> socket.assigns[:current_user] end |
Function to extract current user from socket |
:likes_table_name |
"phoenix_blog_post_likes" |
Database table name for likes |
:site_name |
"Blog" |
Site name for Open Graph and JSON-LD |
:default_og_image |
nil |
Fallback OG image URL when post has no featured image |
:twitter_site |
nil |
Twitter/X site handle (e.g. "@myhandle") |
:locale |
"en_US" |
Open Graph locale |
| Option | Default | Description |
|---|---|---|
:on_mount |
[] |
Additional on_mount hooks |
:as |
:phoenix_blog |
Live session name |
:layout |
nil |
Custom app layout {Module, :template} |
:root_layout |
nil |
Override root layout (e.g. a dedicated blog_root layout) |
:index_view |
PhoenixBlog.Web.Live.Public.Index |
Custom LiveView for the blog index |
:show_view |
PhoenixBlog.Web.Live.Public.Show |
Custom LiveView for the blog post page |
| Option | Default | Description |
|---|---|---|
:on_mount |
[] |
Authentication hooks (recommended) |
:as |
:phoenix_blog_dashboard |
Live session name |
:layout |
nil |
Custom app layout {Module, :template} |
- Responsive card grid with featured images
- Full-text search
- Tag filtering
- Pagination
- SEO-friendly slugs
- Full SEO metadata (Open Graph, Twitter Cards, JSON-LD, canonical URLs)
- Optional likes with per-post counts
- Optional social share buttons (Copy link, Twitter/X, Facebook, LinkedIn)
- Post list with search, status filter, and pagination
- Editor.js rich text editor with auto-save
- Draft/Published/Archived status management
- SEO metadata (description, slug, tags)
- Featured image support (via URL)
- Soft delete and restore
The following tools are included out of the box:
- Header (h1-h6)
- List (ordered/unordered)
- Quote (with caption)
- Code (code blocks)
- Table (rows/cols)
- Delimiter (section break)
- Embed (YouTube, Twitter, Vimeo, Instagram, CodePen)
- Image (via URL)
The PhoenixBlog module exposes functions for querying posts from your own code:
# Latest published posts (for widgets, feeds, etc.)
PhoenixBlog.list_latest_published_posts(5)
# Paginated published posts with filters
PhoenixBlog.list_published_posts(page: 1, per_page: 10, tag: "elixir")
# Single post by slug
PhoenixBlog.get_post_by_slug!("my-post")
# Admin CRUD
PhoenixBlog.create_post(%{"title" => "Hello", "status" => "draft", ...})
PhoenixBlog.update_post(post, %{"status" => "published"})
PhoenixBlog.publish_post(post)
PhoenixBlog.soft_delete_post(post)
PhoenixBlog.restore_post(post)


