Published on

Static blog post revision history

The publication date of a post tells us when the post went live, but it doesn't tell you if the code still works. For technical posts, especially ones that depend on third-party libraries, posts can easily become out of date. Some years ago I started detailing how I build this blog with Phoenix 1.7. As I was moving this blog to Phoenix 1.8, I wanted to bring that blog post up to date as well.

With a static blog, there's no database to keep track of changes. The metadata exists in the blog post itself or maybe as part of the git commit history. We can add an updated field, but better yet for technical posts, we can add a revision history.

Blog post revision history

I want to show both when a post was last updated and its revision history. We'll continue from the Atom feed blog post, which allows me to show how to update the AtomXML module. I'll leave out the changes from the preview link image post for simplicity.

First, we add a revision history to the blog post's metadata:

# priv/posts/2026/01-01-hello-world.md
%{
  title: "Hello world",
  tags: ~w(hello),
  description: "This is my first blog post",
  revisions: [
    %{
      date: ~D[2026-03-01],
      note: "A second update."
    },
    %{
      date: ~D[2026-02-01],
      note: "First update."
    }
  ]
}

Next, we update Blog.Post with :revisions and :updated_at fields. Since we previously only had a :date field, I think it makes sense to rename it to :published_at.

:updated_at will be pulled from the latest revision date or fall back to the :published_at value:

# lib/my_blog/blog/post.ex
defmodule MyBlog.Blog.Post do
  @enforce_keys [:id, :title, :body, :description, :tags, :published_at, :updated_at]

  defstruct [
    :id,
    :title,
    :body,
    :description,
    :tags,
    :published_at,
    :updated_at,
    revisions: []
  ]

  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)
    published_at = Date.from_iso8601!("#{year}-#{month}-#{day}")
    revisions = attrs |> Map.get(:revisions, []) |> Enum.sort_by(& &1.date, {:desc, Date})
    updated_at = (List.first(revisions) || %{date: published_at}).date
    attrs = Map.put(attrs, :revisions, revisions)

    struct!(
      __MODULE__,
      [id: id, published_at: published_at, updated_at: updated_at, body: body] ++
        Map.to_list(attrs)
    )
  end
end

Now we can update the templates. I'm adding the new :updated_at value above the content of the post and :revisions history below. We only show the "Last updated" text if :updated_at differs from :published_at, and revision history only shows up if there are any revisions.

# lib/my_blog_web/live/blog_live/show.html.heex
<Layouts.app flash={@flash}>
  <div class="mx-auto max-w-3xl px-4 py-8">
    <.header>
      {@post.title}
      <:subtitle>
        <span class="sr-only">Published on</span>
        <time datetime={@post.published_at} class="text-base-content/40">{@post.published_at}</time>
      </:subtitle>
    </.header>

    <p :if={@post.updated_at != @post.published_at} class="text-sm text-base-content/50 italic">
      Last updated on <time datetime={@post.updated_at}>{@post.updated_at}</time>
    </p>

    <article class="prose dark:prose-invert my-8 max-w-none">
      {raw(@post.body)}
    </article>

    <details :if={@post.revisions != []} class="mt-8 text-sm text-base-content/60">
      <summary class="cursor-pointer italic">Revision history</summary>
      <ul class="mt-2 list-disc space-y-1 pl-6">
        <li :for={entry <- @post.revisions}>
          <time datetime={entry.date} class="text-base-content/40">{entry.date}</time> <span>{entry.note}</span>
        </li>
      </ul>
    </details>
  </div>
</Layouts.app>

In my blog I have metatags for link previews, so I added an article:modified_time tag next to the article:published_time tag in the <head>. The modified time should only be rendered if it differs from publishing date:

# lib/my_blog_web/components/layouts/root.html.heex
<meta :if={assigns[:post]} property="article:published_time" content={@post.published_at} />
<meta
  :if={assigns[:post] && @post.updated_at != @post.published_at}
  property="article:modified_time"
  content={@post.updated_at}
/>

Finally, I update my AtomXML feed to include the latest updated timestamp, along with both published and updated timestamps for entries:

# lib/my_blog_web/atom_xml.ex
defmodule MyBlogWeb.AtomXML do
  use MyBlogWeb, :xml

  def load(_) do
    posts = MyBlog.Blog.list_posts()
    updated_at = posts |> Enum.map(& &1.updated_at) |> Enum.max(Date)

    %{
      title: "Dan Schultzer",
      author: "Dan Schultzer",
      updated_at: updated_at,
      posts: posts
    }
  end

  def to_datetime(date) do
    DateTime.new!(date, ~T[00:00:00])
  end

  def render(assigns) do
    ~X"""
    <?xml version="1.0" encoding="utf-8"?>
    <feed xmlns="http://www.w3.org/2005/Atom">
      <title><%= @title %></title>
      <link href="<%= url(~p"/") %>" />
      <link href="<%= url(~p"/atom.xml") %>" rel="self" />
      <updated><%= to_datetime(@updated_at) %></updated>
      <author>
        <name><%= @author %></name>
      </author>
      <id><%= url(~p"/") %></id>

      <%= for post <- @posts do %>
      <entry>
        <title><%= post.title %></title>
        <link href="<%= url(~p"/posts/#{post.id}") %>" />
        <id><%= url(~p"/posts/#{post.id}") %></id>
        <published><%= to_datetime(post.published_at) %></published>
        <updated><%= to_datetime(post.updated_at) %></updated>
        <summary><%= post.description %></summary>
      </entry>
      <% end %>
    </feed>
    """
  end
end

That's it! You can see how it looks on my first blog.

I don't use revision history for typos or other minor fixes. It's meant for substantial changes that affect the usefulness of a post, such as changes to a library's API. This gives readers context about what has changed since they first read the post.

If we really wanted to go wild with this, we could have used the git commit history to build the revisions, and even show diffs. That's beyond what I need for my blog, but it would be a fun thing to implement.

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