Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

20 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

PhoenixBlog

Latest release View documentation

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_mount hooks

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):

  • <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_image when an image exists), twitter:title, twitter:description, twitter:image
  • <link rel="canonical"> with the clean URL (no query params)
  • JSON-LD structured data (Article schema with headline, author, dates, publisher)

Blog index page (/blog):

  • Open Graph tags with og:type set to website
  • Twitter Card, canonical URL, and JSON-LD (Blog schema)

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

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.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_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)

About

πŸ“š Plug-and-play blog engine for Phoenix with Editor.js integration.

Topics

Resources

Stars

Watchers

Forks

Releases

Contributors

Languages