Heading image for post: Titled URL Slugs in Phoenix

Elixir Phoenix

Titled URL Slugs in Phoenix

Profile picture of Jake Worth

Today I Learned has a feature I love that we call 'titled slugs'. This week I reimplemented it in Phoenix.

Here's an overview of the feature. Visit any post on 'Today I Learned', and the URL looks like this:

https://til.hashrocket.com/posts/61e2f0db67-logrotation-for-a-rails-app

61e2f0db67 is the url_slug for the post, a unique string that does not change. But what's with the rest of the URL? -logrotation-for-a-rails-app is a slugified version of the title of the post. It makes the URL easier to read, and might improve our SEO in some magical way.

Bur what happens if an author changes the title? The slugified version of the title should change, altering the URL and breaking any old links to the post.

To test this scenario, visit this link, and look at the URL in your browser:

https://til.hashrocket.com/posts/61e2f0db67-actually-irrelevant

Surprised? The feature I want to discuss today allows you to remove or replace the title part of the slug, and still load the post. It works because the titled slug is the implied parameter for the post, but we look up the post by the slug itself, without the title. The titled slug is used just for display; while the slug alone makes the post unique.

This is a nicety that I wanted to preserve in our Phoenix port, Tilex, of 'Today I Learned'. Here's the code I wrote to make that happen (Phoenix 1.2.1).

Change the Parameter

In Ruby on Rails, models have an implied parameter of their ID, which you can verify by calling to_param on an instance. Here's the Phoenix equivalent:

# web/models/post.ex

defimpl Phoenix.Param, for: Tilex.Post do
  def to_param(%{slug: slug, title: title}) do
    "#{slug}-#{Tilex.Post.slugified_title(title)}"
  end
end

We define the implementation for the given protocol Phoenix.Param.to_param, pattern matching the slug and title and returning our 'titled slug'.

Slugify the Title

Next, we need to be able to convert a title into a valid URL slug. To do this, we'll implement the slugified_title/1 function seen above. Here's the unit test:

# test/models/post_test.ex

test "can slugify its own title" do
 title = "Hacking Your Shower!!!"
 result = "hacking-your-shower"
 assert Post.slugified_title(title) == result
end

And the implementation:

# web/models/post.ex

def slugified_title(title) do
  title
    |> String.downcase
    |> String.replace(~r/[^a-z0-9\s-]/, "")
    |> String.replace(~r/(\s|-)+/, "-")
end

We take the title, downcase it, remove anything not a space, dash, or alphanumeric character, and replace whitespaces with a dash.

Handle the New Parameter

This application is expecting posts to identify with their ID, so we'll need to change how our controllers and links behave.

First, let's name our new parameter 'titled_slug', to reflect what it is:

# web/router.ex

defmodule Tilex.Router do
  resources "/posts", PostController, param: "titled_slug"
end

When the controller receives 'titled_slug', we must add some logic:

# web/controllers/post_controller.ex

def show(conn, %{"titled_slug" => titled_slug}) do
  [slug|_] = titled_slug |> String.split("-")
  post = Repo.get_by!(Post, slug: slug)
         |> Repo.preload([:channel])
  render(conn, "show.html", post: post)
end

Here, a new parameter (titled_slug) is pattern-matched, chopped up (again, with pattern matching), and used to find a post.

Make sure all your links use the model, not an attribute like .slug or .title, and that's it.

Integration Test

Here's our new, passing integration test (Wallaby 0.16.1):

# test/features/visitor_views_post_test.exs

test "and sees a titled URL slug", %{session: session} do

  post = Factory.insert!(:post, title: "Super Sluggable Title")
  url = visit(session, post_path(Endpoint, :show, post))
    |> current_url

  assert url =~ "#{post.slug}-super-sluggable-title"

  changeset = Post.changeset(post, %{title: "Alternate Also Cool Title"})
  Repo.update!(changeset)
  post = Repo.get(Post, post.id)
  url = visit(session, post_path(Endpoint, :show, post))
    |> current_url

  assert url =~ "#{post.slug}-alternate-also-cool-title"
end

A new title changes how the URL is presented, but not how it works.

Conclusion

Here's the pull request where all these changes were made:

https://github.com/hashrocket/tilex/pull/25

Tilex has been a fun project, forcing us to figure out how features we built in Ruby on Rails might be recreated in Elixir. If you build this feature in a Phoenix application of your own, I'd love to hear your experience and any improvements you might have made.

Photo Credit: Untitled, Nick Tiemeyer, unsplash.com, https://unsplash.com/photos/tNGcZlycLtQ. Accessed 18 March 2017.

More posts about testing Elixir Phoenix web