PhoenixBlog

Latest releaseView documentation

Plug-and-play blog engine for Phoenix with Editor.js integration.

editorjs screenshot

Installation

Add phoenix_blog to your list of dependencies in mix.exs:

def deps do
[
{:phoenix_blog, "~> 0.1"}
]
end

Setup

1. Configure the repo

# config/config.exs
config :phoenix_blog, repo: MyApp.Repo

2. Create and run the migration

mix ecto.gen.migration add_phoenix_blog
defmodule MyApp.Repo.Migrations.AddPhoenixBlog do
use Ecto.Migration
def up, do: PhoenixBlog.Migration.up()
def down, do: PhoenixBlog.Migration.down()
end
mix ecto.migrate

3. 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}]
end

That'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}

article preview

SEO

Every blog page ships with complete SEO metadata — zero configuration required.

What's rendered automatically

Blog post pages (/blog/:slug):

Blog index page (/blog):

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 locale

Likes & 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: true

Likes

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/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
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_likes
defmodule MyApp.Repo.Migrations.AddPhoenixBlogLikes do
use Ecto.Migration
def up, do: PhoenixBlog.Migration.up(version: 2)
def down, do: PhoenixBlog.Migration.down(version: 2)
end
mix ecto.migrate

Passing 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
end

Then pass it to the blog routes:

scope "/" do
pipe_through :browser
phoenix_blog "/blog",
on_mount: [{MyAppWeb.UserAuthHook, :maybe_assign_user}]
end

When 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"
/>

recent posts

AttributeDefaultDescription
blog_path(required)Path where your blog is mounted
count3Number of posts to display
title"Latest Posts"Section heading (nil to hide)
classnilAdditional 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.

AttributeDefaultDescription
blog_path(required)Path where your blog is mounted
per_page12Items per page
show_searchtrueShow search bar
show_tagstrueShow tag filter buttons
show_herofalseShow hero banner with title and post count
classnilAdditional 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.CustomBlogShow
defmodule 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.

AttributeDefaultDescription
post(required)A %PhoenixBlog.Post{} struct
blog_path"/blog"Path for back and tag links
show_back_linktrueShow "Back to blog" link
show_headertrueShow title, tags, author, and date
show_featured_imagetrueShow the featured image
show_tags_footertrueShow tags section at the bottom
classnilCSS 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

OptionDefaultDescription
:repo(required)Your Ecto repo module
:table_name"phoenix_blog_posts"Database table name
:likes_enabledfalseEnable post likes
:share_enabledfalseEnable social share buttons
:get_current_userfn socket -> socket.assigns[:current_user] endFunction 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_imagenilFallback OG image URL when post has no featured image
:twitter_sitenilTwitter/X site handle (e.g. "@myhandle")
:locale"en_US"Open Graph locale

Router Options

phoenix_blog/2

OptionDefaultDescription
:on_mount[]Additional on_mount hooks
:as:phoenix_blogLive session name
:layoutnilCustom app layout {Module, :template}
:root_layoutnilOverride root layout (e.g. a dedicated blog_root layout)
:index_viewPhoenixBlog.Web.Live.Public.IndexCustom LiveView for the blog index
:show_viewPhoenixBlog.Web.Live.Public.ShowCustom LiveView for the blog post page

phoenix_blog_dashboard/2

OptionDefaultDescription
:on_mount[]Authentication hooks (recommended)
:as:phoenix_blog_dashboardLive session name
:layoutnilCustom app layout {Module, :template}

Features

Public Blog (/blog)

Admin Dashboard (/admin/blog)

Editor.js Tools

The following tools are included out of the box:

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)