SEO Friendly URLs in Phoenix 1.3

Posted November 14, 2017 by Carl Reyes in Development

While Vaporware is primarily a Rails and React shop, we're always exploring new technologies that'll help our clients create the best possible product. Lately, I've been doing a deep dive into Elixir and came across the need to have some SEO friendly titles for a blog-like feature. There's a few resources out there already but none that I could find in the new Phoenix 1.3 world. We're going to start a new app from scratch but for the sake of brevity I'm only going to talk about the title-relevant parts.

I assume you already have a Phoenix 1.3 environment up and running. If you don't... start here.

Getting started

We'll begin by starting up a new app. In your terminal type

mix blog

cd into your new blog directory. First we'll create the database and then let's create a Blogs context where we'll keep our posts table. To read more about contexts I'd recommend checking out the official docs.

In your terminal type

mix ecto.create

When that's done

mix phx.gen.html Blogs Post posts title:string body:text

This scaffolds pretty much everything that you need to get a basic blog going which is great for us!. Don't forget to add

resources /posts, PostController

to your lib/blog_web/router.ex file. We can now run mix ecto.migrate and when that's done mix phx.server and we'll have a fully working blog! Navigate to http://localhost:4000/posts to try it out!

Friendly URLs

After you've created a post or two you can click "Show" and you'll get to the post's specific page. Check out the URL and you'll see http://localhost:4000/posts/1 - while this is perfectly fine and functional, it's not the most appealing looking thing. Just like titles, URLs are an important part of search engine optimization. In addition to rankings, more readable URLs are more likely to be clicked when showing up in search result and also look much better when being shared on places like Twitter.

We're going to use a hyphenated version of our title for our URLs. You'll often see these referred to as slugs. First, we need to create them (we'll also reset the db to delete the old posts).

In your terminal...

mix ecto.reset
mix ecto.gen.migration add_slug_to_posts

Open up that migration file

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

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

We add a column called slug that's a String - that part is self explanatory. We also want to create a unique index because we're going to be doing our Post queries by slug. We query resources by url parameter (e.g. /posts/1 will find the Post with id = 1). Because we're replacing id with a slug in the url, we'll also need to replace our queries with a slug. Let's do that now.

# lib/blogs/post.ex

Add this to the bottom of the post file

defimpl Phoenix.Param, for: Blog.Blogs.Post do
  def to_param(%{slug: slug}) do

The above code implements a Protocol in Elixir. I'm not going to go in depth with Protocols (you can here) but basically they're elixir's way of handling polymorphism. This allows us to implement the Param.to_param protocol returning the Post's slug instead of it's id. We've now stored the slug in the database and told Phoenix to use it in to_param. Now we need to actually transform our title into a slug and cast it in our changeset.

# lib/blogs/post.ex

schema "posts" do field :title, :string field :body, :string field :slug, :string # add this line

timestamps() end

@doc false def changeset(%Post{} = post, attrs) do attrs = Map.merge(attrs, slugify_title(attrs)) # merge the slug with attrs

post |> cast(attrs, [:title, :body, :slug]) # make sure to cast :slug |> validate_required([:title, :body]) end

transform title into our slug

defp slugify_title(%{"title" => title}) do
  slug =
    |> String.downcase
    |> String.replace(~r/[^a-z0-9\s-]/, "")
    |> String.replace(~r/(\s|-)+/, "-")

%{"slug" => slug} end

need to have a function w/o title for our :new controller method

defp slugify_title(\_params) do

Whew that was a lot but really it was pretty simple. We wrote a slugify_title function that takes our existing title; makes them all lowercase letters, then gets rid of all invalid characters and separates the words with a hyphen. We then merge that new map with our existing attrs map and cast that to our post and viol a - we have our title-as-slug stored in the db!

Using our new slug

As we mentioned earlier, Phoenix uses what's in the route param to find our posts. We can see this if we open up our PostController, let's open that up and have pattern match our new slugs instead of the post's id.

# lib/blog_web/controllers/post_controller.ex

def show(conn, %{"slug" => slug}) do post = Blogs.get_post!(slug) ... end

def edit(conn, %{"slug" => slug}) do post = Blogs.get_post!(slug) ... end

def update(conn, %{"slug" => slug}, %{"post" => post_params}) do post = Blogs.get_post!(slug) ... end

def delete(conn, %{"slug" => slug}) do post = Blogs.get_post!(slug) ... end

That was easy! All we did was replace all the instances of id with slug. If you're a vim user... you can do :%s/id/slug/g and be in a couple seconds! Notice how we're changing what we pass to Blogs.get_post! so we'll need to update that function to use slugs too. We'll find that in our Blogs context file.

# lib/blogs/blogs.ex

def get_post!(slug), do: Repo.get_by!(Post, slug: slug)

We're almost done, the last thing we need to do is tell the router what it parameter it should expect to use... we already know what that is.

# lib/blog_web/router.ex


resources "/posts", PostController, param: "slug"


That's it! Now when you create a new post you should see our new SEO friendly title in the URL! All the routes work around that now rather than the boring id.

SEO friendly conclusion

Hopefully you've learned how to slugify your URLs and maybe even a bit about Phoenix in general. You can find all a repo with this example app on my Github.

Feel free to hit me up on Twitter @carljreyes if you have any questions or just want to chat... and/or if you want Vaporware to get started on building out your app (maybe in Elixir)!


Related Insights