Published on

Welcome to my blog

Why go through the effort of building my own blog and not just use ghost?

If you had asked me a few weeks ago I would have agreed that it sounds like a waste of time. I’ve tried so many variations of blogs. From early days with WordPress, to static website generators like jekyll and hexo, to hosted versions like medium. All have their strengths and weaknesses, but I have never found one that suited me perfectly.

Then I read José Valim’s Dashbit blog post on how they rolled their own blog. It appeared there was an effortless way to set up a blog in a Phoenix app with NimblePublisher!

I would have the best of both worlds. It’s kinda static with no database, it’s kinda a publishing system with Phoenix. And it’s all Elixir which is what I do all day. If one day I no longer work with Elixir professionally I would still have a good excuse to keep up with Phoenix development 😄

So that’s what I did over the weekend. Below I’ll detail how I set it up in Phoenix 1.7 using LiveView.

I also recommend reading Elixir School’s lesson and David Bernheisel’s blog post.

First, we’ll generate a new Phoenix app:

mix my_blog --no-ecto

Add NimblePublisher to the dependencies:

# mix.exs
defmodule MyBlog.MixProject do
  use Mix.Project

  # ...

  def deps do
      # ...
      {:nimble_publisher, "~> 0.1.1"}


Add the Blog context and Blog.Post module:

# lib/my_blog/blog.ex
defmodule MyBlog.Blog do
  alias MyApp.Blog.Post

  use NimblePublisher,
    build: Post,
    from: Application.app_dir(:my_blog, "priv/posts/**/*.md"),
    as: :posts,
    highlighters: [:makeup_elixir, :makeup_erlang]

  @posts Enum.sort_by(@posts, & &, {:desc, Date})

  def list_posts, do: @posts

  @tags @posts |> Enum.flat_map(& &1.tags) |> Enum.uniq() |> Enum.sort()

  def list_tags, do: @tags

  defmodule NotFoundError do
    defexception [:message, plug_status: 404]

  def get_post_by_id!(id) do
    Enum.find(list_posts(), &(& == id)) ||
      raise NotFoundError, "post with id=#{id} not found"

  def list_posts_by_tag!(tag) do
    case Enum.filter(list_posts(), &(tag in &1.tags)) do
      [] -> raise NotFoundError, "posts with tag=#{tag} not found"
      posts -> posts
# lib/my_blog/blog/post.ex
defmodule MyBlog.Blog.Post do
  @enforce_keys [:id, :title, :body, :description, :tags, :date]
  defstruct [:id, :title, :body, :description, :tags, :date]

  def build(filename, attrs, body) do
    [year, month_day_id] = filename |> Path.rootname() |> Path.split() |> Enum.take(-2)
    [month, day, id] = String.split(month_day_id, "-", parts: 3)
    date = Date.from_iso8601!("#{year}-#{month}-#{day}")
    struct!(__MODULE__, [id: id, date: date, body: body] ++ Map.to_list(attrs))

Create a new blog post:

# priv/posts/2023/
  title: "Hello world",
  tags: ~w(hello),
  description: "This is my first blog post"
Hello world!

Set up the routes:

# lib/my_blog_web/router.ex
defmodule MyBlogWeb.Router do
  use MyBlogWeb, :router

  # ...

  scope "/", MyBlogWeb do
    pipe_through :browser

    live "/", BlogLive, :index
    live "/tags/:tag", BlogLive, :index
    live "/posts/:slug", BlogLive, :show

  # ...

Set up the BlogLive module and the templates:

# lib/my_blog_web/live/blog_live.ex
defmodule MyBlogWeb.BlogLive do
  use MyBlogWeb, :live_view

  embed_templates "blog_live/*"

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket}

  @impl true
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}

  defp apply_action(socket, :index, %{"tag" => tag}) do
    |> assign(:page_title, tag)
    |> assign(:posts, MyBlog.Blog.list_posts_by_tag!(tag))
    |> assign(:tag, tag)

  defp apply_action(socket, :index, _params) do
    assign(socket, :posts, MyBlog.Blog.list_posts())

  defp apply_action(socket, :show, %{"slug" => slug}) do
    post = MyBlog.Blog.get_post_by_id!(slug)

    |> assign(:page_title, post.title)
    |> assign(:post, post)

  @impl true
  def render(assigns) do
    apply(__MODULE__, assigns.live_action, [assigns])
# lib/my_blog_web/live/blog_live/index.html.eex
  <li :for={post <- @posts} class="py-4">
    <article class="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0">
        <dt class="sr-only">Published on</dt>
        <dd class="text-base font-medium leading-6 text-gray-500">
          <time datetime={}><%= %></time>
      <div class="space-y-3 xl:col-span-3">
          <h3 class="text-2xl font-bold leading-8 tracking-tight">
            <.link navigate={~p"/posts/#{}"} class="text-gray-900">
              <%= post.title %>
          <ul class="flex flex-wrap">
            <li :for={tag <- post.tags} class="mr-3">
              <.link navigate={~p"/tags/#{tag}"} class="text-sm font-light uppercase text-black/50">
                <%= tag %>
        <div class="prose max-w-none text-black/50">
          <%= post.description %>
# lib/my_blog/live/blog_live/show.html.eex
<div class="mx-auto px-4 max-w-4xl">
  <div class="py-5 text-center">
    <p class="text-sm">
      <dt class="sr-only">Published on</dt>
      <dd class="text-base font-medium leading-6 text-white/50">
        <time datetime={}><%= %></time>
    <h1 class="text-5xl font-extrabold"><%= @post.title %></h1>

  <div class="prose py-8 max-w-4xl text-lg">
    <%= raw @post.body %>

Finally, update live reload to improve the dev experience (otherwise you have to restart the server constantly):

config :my_blog, MyBlogWeb.Endpoint,
  live_reload: [
    patterns: [
      # ...

Now run mix deps.get, mix phx.server, and visit http://localhost:4000/.

That was easy!

The only thing you are missing is to style it which seems to always be what takes the longest when starting a new blog 😅

Hi, I'm Dan Schultzer, I write in this blog, work a lot in Elixir, maintain several open source projects, and help companies streamline their development process