Today I’d like to highlight something I built with some colleagues that I think is cool: the dynamic paths of Hashrocket’s daily learning site, Today I Learned (TIL).

Check out the browser bar here:

TIL Screenshot

The URL it contains is:

https://til.hashrocket.com/posts/ee0a1bc878-parameters-filtering

What do you notice? Here’s my list:

  • After posts/, the path starts with ee0a1bc878 (?)
  • That’s followed by a dash, then the post title in kebab-case (parameters-filtering)
  • When you visit the same URL with an altered kebab-case title (e.g. ee0a1bc878-nope), you get the same page 😵‍💫
  • When an admin changes the post’s title (e.g. “Rails Parameters FTW”), the URL changes too(ee0a1bc878-rails-parameters-ftw) 😳

There are some sneaky little features in there! Follow me into the abyss, if you like.

The ‘why’ behind these features

With these features, we tried to accomplish the following not-quite-complementary goals:

  • Posts have a permalink that never changes
  • The permalink doesn’t leak information
  • The link contains words that say what the post is about
  • Post titles can change, and the link changes, but the permalink doesn’t

As a Hashrocket alum, I don’t have access to TIL’s analytics anymore. But during my tenure we got millions of page views to TIL per year. I’m proud of this design, and I suspect it was part of that success.

The ‘how’ behind these features

Here’s a bit about how each of these features works. We built TIL twice, so you can find two competing versions of this behavior on GitHub.

Today I’ll be focusing on the Elixir implementation.1

Finding the post: The slug (ee0a1bc878)

First, we wanted each post to have a permanent URL so we could track usage and keep URLs on the site from ever breaking.

We called this identifier a “slug”. Each post generates an opaque 10-character slug on create.

# lib/tilex/blog/post.ex
def generate_slug do
  16
  |> :crypto.strong_rand_bytes()
  |> :base64.encode()
  |> String.downcase()
  |> String.replace(~r/[^a-z0-9]/, "")
  |> String.slice(0, 10)
end

We put a database non-null constraint and unique index on that field, too:

# priv/repo/migrations/20170106200911_add_slug_to_post.exs
defmodule Tilex.Repo.Migrations.AddSlugToPost do
  use Ecto.Migration

  def change do
    alter table(:posts) do
      add :slug, :string, null: false
    end

    create unique_index(:posts, [:slug])
  end
end

Why not use the database primary key for the “slug” concept? Some of it was personal preference. I don’t like seeing /things/1, /things/2 in the browser bar. Reasons:

  • It’s leaky: “This company has only written three blog posts, I guess they haven’t been around long.”
  • It can encourage URL hacking: “I see a post #4 and a post #2 but no post #3, I wonder what’s there?”

Meaningless unique identifiers is one of those things I’ve learned you always seem to want, so I tend to do it early.

Showing the post: The title display (parameters-filtering)

Another thing we wanted was some version of the post title to be included in the URL. URLs with human-readable words feel ergonomic and trustworthy.

When Phoenix builds a path, it calls Phoenix.Param.to_param/1. Post implements that as:

# lib/tilex/blog/post.ex
defimpl Phoenix.Param, for: Post do
  def to_param(%{slug: slug, title: title}) do
    "#{slug}-#{Post.slugified_title(title)}"
  end
end

What is slugified_title?

# lib/tilex/blog/post.ex
def slugified_title(title) do
  title
  |> String.downcase()
  |> String.replace(~r/[^A-Za-z0-9\s-]/, "")
  |> String.replace(~r/(\s|-)+/, "-")
end

This function takes “Parameters Filtering” and returns “parameters-filtering”.

When we put these both together, we get a query parameter we call the “titled_slug”.

# lib/tilex_web/router.ex
resources "/posts", PostController, param: "titled_slug"

On post lookup, we extract just the slug:

# lib/tilex_web/controllers/post_controller.ex
defp extract_slug(conn, _) do
  case extracted_slug(conn.params["titled_slug"]) do
    {:ok, slug} ->
      assign(conn, :slug, slug)
    :error ->
      TilexWeb.ErrorView.render_error_page(conn, 404)
  end
end

defp extracted_slug(<<slug::size(10)-binary, _rest::binary>>), do: {:ok, slug}
defp extracted_slug(_), do: :error

This function takes the first 10 bytes of titled_slug and assigns that as :slug. show/2 then queries where: p.slug == ^slug:

# lib/tilex_web/controllers/post_controller.ex
def show(%{assigns: %{slug: slug}} = conn, _) do
  post =
    from(p in Post,
      where: p.slug == ^slug
    )
    |> Posts.published()
    |> Repo.one!(slug: slug)
    |> Repo.preload([:channel])
    |> Repo.preload([:developer])

  conn
  # ...
  |> render(:show, post: post)
end

The rest is ignored— it’s for display only.

Putting it all together

All together: the link persists, the user sees words, and the words reflect the title as it changes.

This was a lot of fun to build. Thanks to all the contributors to TIL who made this work.

Bigger picture: we can build cool stuff. If you don’t like the default path strategy of your framework, make your own. It’s your app. I’m glad we did it for TIL enough that I still think about it sometimes, a decade later.


  1. A quick disclaimer about the Elixir code: it’s from nearly a decade ago. There may be more conventional ways of doing some of this today. At Hashrocket, we were early to the Phoenix framework and enjoyed building these features from scratch. ↩︎