PhoenixBlog
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
Installation
Add phoenix_blog to your list of dependencies in mix.exs:
def deps do
[
{:phoenix_blog, "~> 0.1"}
]
endSetup
1. Configure the repo
# config/config.exs
config :phoenix_blog, repo: MyApp.Repo2. Create and run the migration
mix 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.migrate3. Serve static assets
Add to your endpoint.ex, before the existing Plug.Static:
plug Plug.Static,
at: "/phoenix_blog",
from: {:phoenix_blog, "priv/static"}4. Register the JS hooks
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.
5. Add Tailwind source
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";6. Mount routes
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.
7. Add SEO tags to your root layout
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}SEO
Every blog page ships with complete SEO metadata — zero configuration required.
What's rendered automatically
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)
Optional SEO configuration
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 localeLikes & Share (Optional)
Both 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
Likes 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/config.exs
config :phoenix_blog,
likes_enabled: true,
get_current_user: fn socket ->
case socket.assigns[:current_scope] do
%{user: user} -> user
_ -> nil
end
end
The 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.
Likes migration
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.migratePassing user identity to the blog
The 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.
Share buttons
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.
Usage
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.
Embedding Recent Posts
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 |
Embedding the Blog Feed
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
end
Post 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 |
Custom post cards with slots
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.
Custom Blog Post View
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
end
Call 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 |
Rendering Editor.js Content
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).
Excerpt Extraction
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 length
Returns the first paragraph's text, stripped of HTML tags and truncated with ....
Configuration
| 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 |
Router Options
phoenix_blog/2
| 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 |
phoenix_blog_dashboard/2
| Option | Default | Description |
|---|---|---|
:on_mount | [] | Authentication hooks (recommended) |
:as | :phoenix_blog_dashboard | Live session name |
:layout | nil |
Custom app layout {Module, :template} |
Features
Public Blog (/blog)
- 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)
Admin Dashboard (/admin/blog)
- 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
Editor.js Tools
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)
Public API
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)