#8: SEO Friendly URLs with Phoenix

Episode notes

Elixir 1.3.4

Phoenix Phoenix 1.2.1

Source code on GitHub

Here we have a site that displays the title and description of a list of books. When we view a post you can see it’s using the default route provided by Phoenix with the ID of the post in the URL.

This is great for getting started. But how could we make URLs that are more descriptive and SEO friendly?

One way we can do this is by adding a slug. We’ll create our slugs by making simple URL safe versions or our titles. Let’s get started.

First we’ll need to add a ‘slug’ column to our ‘posts’ table. We’ll do that by generating a migration.

$ mix ecto.gen.migration add_slug_to_posts

Then we’ll add a column named ‘slug’ and set it as a string. Because we’re going to use this field to lookup our posts we’ll add an index to our slug field to make this faster.

priv/repo/migrations/{{timestamp}}_add_slug_to_posts.exs

defmodule Teacher.Repo.Migrations.AddSlugToPosts do
  use Ecto.Migration

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

    create index(:posts, [:slug], unique: true)
  end
end

Now we can migrate the database:

$ mix ecto.migrate

With that we can add the field to our ‘posts’ schema and update the cast/3 function in our changeset to allow the ‘slug’ field.

web/models/post.ex

defmodule Teacher.Post do
  use Teacher.Web, :model

  schema "posts" do
    field :title, :string
    field :body, :string
    field :slug, :string

    timestamps()
  end

  def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:title, :body, :slug])
    |> validate_required([:title, :body]) 
  end
end

Now that we have slugs for out posts, how do we use them? Elixir protocols are an amazing way to build polymorphic interfaces and since ‘Phoenix.Param’ the module that converts data structures into URL parameters is a protocol we can take advantage of this and create our own implementation.

We’ll go to the bottom of post.ex and define our custom implementation for the ‘post’ module. Now we’ll define the ‘to_param’ function and pattern match to get our ‘slug’. And then we’ll simply return the ‘slug’ to use.

web/models/post.ex

…
defimpl Phoenix.Param, for: Teacher.Post do
  def to_param(%{slug: slug}) do
    "#{slug}"
  end
end

Now let’s update our controller to look up the ‘post’ based on its ‘slug’.

web/controllers/post_controller.ex

defmodule Teacher.PostController do
…
  def show(conn, %{"id" => id}) do
    post = Repo.get_by!(Post, slug: id)
    render(conn, "show.html", post: post)
  end
  
  def edit(conn, %{"id" => id}) do
    post = Repo.get_by!(Post, slug: id)
    changeset = Post.changeset(post)
    render(conn, "edit.html", post: post, changeset: changeset)
  end

  def update(conn, %{"id" => id, "post" => post_params}) do
    post = Repo.get_by!(Post, slug: id)
    …
  end

  def delete(conn, %{"id" => id}) do
    post = Repo.get_by!(Post, slug: id)
    …
  end
end

And if we go back to our page and click on an article, you’ll see the article’s ‘slug’ is now used in the URL - perfect!

But what about new posts - when they’re created how will posts get their slugs. Let’s keep this simple and generate slugs automatically from the title of our posts.

From our Post module we’ll create a private function called ‘slug_map’, which will take our existing params and pattern match off the “title” key. We’ll use this to create our slug.

Since our titles are simple, we’ll use String.downcase/1 and then String.replace/3 to create our ‘slugs’.

This will break on the post’s new action since no title will be present. Let’s fix that by adding another ‘slug_map’ function to handle all other instances and we’ll have it return an empty map.

Going back to the beginning of the changeset let’s merge our params with what’s returned from our ‘slug_map’ function.

One sidenote: for a more elegant way to generate and save slugs, i’d recommend checking out the Programming Phoenix book.

web/models/post.ex

defmodule Teacher.Post do
  …

  def changeset(struct, params \\ %{}) do
    params = Map.merge(params, slug_map(params))

    struct
    |> cast(params, [:title, :body, :slug])
    |> validate_required([:title, :body])
  end

  defp slug_map(%{"title" => title}) do
    slug = String.downcase(title) |> String.replace(" ", "-")
    %{"slug" => slug}
  end
  defp slug_map(_params) do
    %{}
  end

end

Now we can create or update posts and our titles will be translated and saved as ‘slugs’. However there’s one last thing we can do to simplify our code - the ‘Phoenix.Param’ protocol is derivable.

Let’s go back to our ‘post.ex’ file, since we’re using Ecto in the project we’ll need to derive the protocol before the schema. We can also delete our custom implementation we previously used.

web/models/post.ex

defmodule Teacher.Post do
  use Teacher.Web, :model
  
  @derive {Phoenix.Param, key: :slug}
  schema "posts" do
  …

end

And going to our posts, they still work.