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/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_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

Attribute Default Description
blog_path(required) Path where your blog is mounted
count3 Number of posts to display
title"Latest Posts" Section heading (nil to hide)
classnil 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_page12 Items per page
show_searchtrue Show search bar
show_tagstrue Show tag filter buttons
show_herofalse Show hero banner with title and post count
classnil 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.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.

Attribute Default Description
post(required) A %PhoenixBlog.Post{} struct
blog_path"/blog" Path for back and tag links
show_back_linktrue Show "Back to blog" link
show_headertrue Show title, tags, author, and date
show_featured_imagetrue Show the featured image
show_tags_footertrue Show tags section at the bottom
classnil 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_enabledfalse Enable post likes
:share_enabledfalse Enable social share buttons
:get_current_userfn 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_imagenil Fallback OG image URL when post has no featured image
:twitter_sitenil 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
:layoutnil Custom app layout {Module, :template}
:root_layoutnil Override root layout (e.g. a dedicated blog_root layout)
:index_viewPhoenixBlog.Web.Live.Public.Index Custom LiveView for the blog index
:show_viewPhoenixBlog.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
:layoutnil Custom 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)