Subscribe to access all episodes. View plans →

#113: SEO Friendly URLs Revised

Published May 30, 2020

Elixir 1.10

Phoenix 1.5

Follow along with the episode starter on GitHub


Here we have an application that lists some movies. When we can click to view a movie, we’re taking to that movies page that has the full description and some extra information. And if we look at the URL we see that it’s using the movie’s ID from the database. While this works, it’s not very descriptive.

In this episode let’s update the URL to use a “slug”, And to for our “slugs” they’ll be URL safe versions or our titles. This will give each movie page a nice, readable URL. This will make our page more descriptive and help search engines to better recognize what the page is about.

Before we get started writing any code, let’s take a quick look at at our movie.ex module. We have three fields, the “summary”, “title”, and “year”. And in our changeset function we’re using the unique_constraint function to ensure each title is unique. We’ll want our slug to be a field on our movie so let’s create a new column in our movies table to represent that. From the command line we’ll generate a new Ecto migration.

$ mix ecto.gen.migration add_slug_to_movies
* creating priv/repo/migrations/{timestamp}_add_slug_to_movies.exs

Let’s open that new migration file. Since our new column will be for our movies table, let’s call alter table(:movies) and then we’ll add a :slug, which we’ll want to be a :string. Because we’re going to using this field to lookup our movies let’s add an index to our slug field to speed up the database query.

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

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

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

    create index(:movies, [:slug])
  end
end

Then we can go back to the command line and migrate our database.

$ mix ecto.migrate
...

Great, let’s go back to our Movie module. And we’ll add slug as a field in our movies schema and then in our changeset function we wont need to include it in our attributes to cast, since we’ll be creating it from the movie’s title. But let’s go ahead and make it required to ensure each movie has one.

Now we need to convert the movie title into a slug. To do that let’s create a new private function called build_slug that will take the changeset. Then in the function we’ll call the Ecto.Changeset.get_field function. This will return the title from the changeset’s changes first or - if no changes exist - from the existing data. This will make it easier to update the existing movie records we have. Once we have our title we’ll build the slug and then update our changeset.

Then to create our slug let’s use the slugify package. Slugify offers some nice customizations so you can generate slugs in different formats. To use it in our project let’s grab the package from Hex and then add it to our Mixfile.

mix.exs

...

defp deps do

  ...  

  {:slugify, "~> 1.3"},

  ...

end

...
Then we'll go to the command line and run `mix deps.get`. ``` $ mix deps.get ... ``` Then going Back to our `movie.ex` module, we'll call `Slug.slugify` to create our `slug` from our `title`. And we'll update our changeset with our slug using `Ecto.Changeset.put_change`. Then let's only call this if there's a `title` returned. If we don't get a `title` back we can simply return the `changeset` as-is. Now we can add our new `build_slug` function to our `changeset`'s pipeline. One last change we'll need to make in this module, is to tell Phoenix to use use our `slug` instead of the database ID in the URL. To do this we'll derive the `Phoenix.Param` protocol specifying the key to use, in this case our `slug`.
lib/teacher/film/movie.ex

defmodule Teacher.Film.Movie do
  use Ecto.Schema
  import Ecto.Changeset

  @derive {Phoenix.Param, key: :slug}
  schema "movies" do
    field :summary, :string
    field :title, :string
    field :year, :integer
    field :slug, :string

    timestamps()
  end

  @doc false
  def changeset(movie, attrs) do
    movie
    |> cast(attrs, [:title, :summary, :year, :slug])
    |> unique_constraint(:title)
    |> build_slug()
    |> validate_required([:title, :summary, :year, :slug])
  end

  defp build_slug(changeset) do
    if title = get_field(changeset, :title) do
      slug = Slug.slugify(title)
      put_change(changeset, :slug, slug)
    else
      changeset
    end
  end

end

Now that our URL is updated to use our movie `slug`, we'll need to update how we lookup our movies from the database. Let's open our `Film` module and the `get_movie` function is what we're using to lookup a given movie by it's ID. Let's change this to use the `slug`. We'll also want to update our `@doc`.
lib/teacher/film.ex

...

@doc """
Gets a single movie.

Raises `Ecto.NoResultsError` if the Movie does not exist.

## Examples

  iex> get_movie!("movie-title")
  %Movie{}

  iex> get_movie!("doesnt-exist")
  ** (Ecto.NoResultsError)

"""
def get_movie!(slug), do: Repo.get_by!(Movie, slug: slug)

...

Now before we test our changes out, we need to create slugs for our existing movies. To do that let's run our app inside `IEx`. Once everything starts up, I'll clear the screen here to better see. Then we'll `alias Teacher.Film` to get all of our movies. Now we can loop through each movie and update it with `Film.update_movie` passing in our movie, and then an empty map. If we setup everything correctly, our `build_slug` function will pull the title from each movie and generate a slug for it. It looks like our records were updated. ``` $ iex -S mix phx.server > alias Teacher.Film Teacher.Film > movies = Film.list_movies() [ ... ] > Enum.each(movies, fn(movie) -> > Film.update_movie(movie, %{"title" => movie.title}) > end) ... :ok ``` Now let's go to the browser and if view a movie - great our slugs are now being used. Let's test our creating a new movie. We'll click add our "New Movie" link to trigger the form modal and I'll just enter some info for another movie and when we save our movie - we're taken to our index page where we can see our new movie is added to our list. And clicking to the movie's page works - and we see the URL is using the slug here too. Now let's test one more thing - editing a movie title. If we edit this movie title. We get an error - it's using our old movie slug. We're using Phoenix LiveView to manage our movies and the LiveView and templates we have were created with the `phx.gen.live` generator. This created a `FormComponent` module to handle saving, updating, and validating our movie form. Let's open that. Here in the `save_movie` function, we can see it's redirecting to a `return_to` URL. This is based off the old movie record - before we updated the title, which created a new slug. Let's update this to always redirect to the movie show path, using the update movie record. And since we're using the updated `movie` we need to make sure we don't ignore it in the OK tuple above.
lib/teacher_web/live/movie_live/form_component.ex

...

defp save_movie(socket, :edit, movie_params) do
  case Film.update_movie(socket.assigns.movie, movie_params) do
    {:ok, movie} ->
      {:noreply,
       socket
       |> put_flash(:info, "Movie updated successfully")
       |> push_redirect(to: Routes.movie_show_path(socket, :show, movie))}

    {:error, %Ecto.Changeset{} = changeset} ->
      {:noreply, assign(socket, :changeset, changeset)}
  end
end

...

Now when we go back to the browser and edit our movie title again the slug is updated and our new URL is used. Our app is now using slugs for movie URLs.

© 2024 HEXMONSTER LLC