bundle install black_friday_deals

Friendly URLs with the FriendlyId gem

By Exequiel Rozas

- November 04, 2024

Friendly or “pretty” URLs are addresses that are unique, human-readable and meant to conceptualize the content that lives within them.

They are easier to share, remember, and they help search engines better understand our site.

In this article we'll explore how to implement the pretty URL feature in a Rails application with and without using the FriendlyId gem.

The goal is to learn what FriendlyId does for us behind the scenes at the same time we learn how to implement a basic version of those features so we can get rid of the dependency if we think it's reasonable.

Let's start:

Why do we need friendly URLs?

There are several reasons to use friendly URLs in our applications.

The first is that they're easier to share and remember since they consist of recognizable words.

The second reason is that search engines favor them because they help their algorithms better understand the content.

For example, a /how-to-make-bread path is more relevant to a bread-making tutorial than /tutorials/151 or the more generic/bread.

Another reason to use friendly URLs is that they hide sequential resources.

Because idsare auto-incremented at the database level, it's easy to infer that /resources/10 was created before /resources/11 and you may not want to share that information with the world.

This is particularly important in situations where this information can be used to extract data about your business or your users.

If an e-commerce app exposes the sequential ids for their orders in the 'thank you' page or in the after-checkout email, we could place a couple of orders in a month and extrapolate the amount of orders they receive per-month.

TIL: The term 'slug', commonly used to refer to the unique and readable part of a pretty URL traces its origin to the traditional typesetting and printing process. It actually referred to a piece of metal used in linotype machines to hold characters or words for printing. It evolved in journalism and publishing to refer to a short and unique label used given to articles or stories.

The default URL behavior in Rails

Without any customization, Rails uses the resource id to uniquely identify it. That's why idsare auto-incremental and unique by default.

When we define routes in our application we also match the resources to a unique id:

# config/routes.rb
resources :articles, only: [:show]

When making a request to /articles/10 the params hash will have the id key set to '10', and we use that to retrieve the desired Article from the database.

# app/controllers/articles_controller.rb
def show
  @article = Article.find(params[:id]) # Returns the Article with an 'id' of 10
end

We may think that all the .find method does is construct a query like:

SELECT * FROM "articles".* FROM "articles" WHERE "articles"."id" = :id LIMIT 1

And it does, but before generating the query, the Ruby method .to_i is called on the id. As long as the string starts with something that can be parsed to an integer, it will return an integer.

This means that a string like 10-and-any-string-we_want gets converted to just the integer 10.

"10".to_i # returns 10
"10-and-any-string-we_want".to_i # returns 10
"and-any-string-we-want-10".to_i # returns 0 which is not what we want.

With this information, let's see how we can have pretty URLs in Rails without using a gem:

Friendly URLs in Rails without a gem

In this tutorial we will explain how to integrate the FriendlyId gem, but we also want to show you how you can make your own version of the feature.

We will explore two ways of doing this, one that's quick and simple and the other would be to try and implement functionality similar to the gem.

Pretty URLs using the to_param method

We know that we can pass a string that starts with an id to our params hash and retrieve the record with that id using the same code for the show action shown above.

So, if your Article model has a title we could pass it manually to the route helpers whenever we link to an Article instance:

# Returns "10-the-article-title"
<%= link_to @article.title, article_path("#{@article.id}-{@article.parameterized_title}) %>

This would work. However, it can quickly become an inconvenience.

That's why Rails calls the .to_param method on the object it receives on the single-resource route helpers to construct a URL for the object.

Out of the box this method, that returns the idas a string by default, looks like this:

# ActiveRecord::Integration concern
def to_param
  return unless id
  Array(id).join(self.class.param_delimiter)
end

This means that we can overwrite the method in our Articleclass, so Rails can generate the pretty URL for us:

# app/models/article.rb
def to_param
  "#{id}-#{parameterized_title}"
end

def parameterized_title
    title.parameterize # Or the logic to parameterize the title to our liking
end

Anytime we call the article_path with an Article instance it would generate the URL where the slug would be a string starting with the object's id followed by the parameterized title.

This would give us a limited but functional pretty URL functionality.

However, there are a couple of things to consider with this approach:

  • There's more than one way to access the resource: even though the method works, we can pass an integer directly to the post_path which will in turn return the same view as before but with the posts/10 URL. Be consistent the way you construct URLs using this method in order to avoid issues like duplicate content penalization because of the indexing of two identical resources with different URLs.
  • The sequential id is still exposed to the user: this defeats the purpose of having pretty URLs to hide some details about our data to the users.

Even though this method of security is called security through obscurity and is not nearly enough to protect our application. Hiding the id from the DOM or our API responses can be a good way to deter users that might not be too technical.

Without exposing the ID in the URL

The second way to implement pretty URLs without a gem in Rails is to add a slug attribute to the model and then querying using .find_by.

Let's assume we have an Article model with a unique slug attribute that we manually define when creating a new record.

The first step would be to replace the to_param method for our model:

# app/models/article.rb
def to_param
  slug
end

So, for our first article called “Welcome to my blog” we can have the slug set to “welcome-to-my-blog”, we can change our controller show action to use find_by!(slug: slug) or find_by_slug!(slug)

# app/controllers/articles_controller
def show
  @article = Article.find_by!(slug: params[:id])
end

But now, we can no longer pass the article's id as a parameter because the finder is expecting a slug. And there are some use cases where we would like to pass either of them.

Maybe admin users require that we explicitly show the record id in the URL because it might be useful for them in some cases.

So, to build a minimal version of friendly_id we could define the requirements as:

  1. We should be able to find records by slug or by id and our finder method should return an Article in both cases.
  2. The slugshould be generated before the record is saved to the database using an attribute we choose using the sluggableclass method.
  3. An ActiveRecord::RecordNotFound error should be raised if the record isn't found.
  4. We should be able to decide which attribute to use in order to generate the slug and we should also be able to customize the slug column in case we want to give it another name.

So, we might write a simple FriendlyUrlable concern to handle this logic for us:

# app/models/concerns/friendly_urlable.rb
module FriendlyUrlable
  extend ActiveSupport::Concern

  included do
    before_save :generate_slug
    class_attribute :slug_column, default: :slug
    class_attribute :sluggable_attribute

    class << self
      def sluggable(attribute, use: :slug)
        self.sluggable_attribute = attribute
        self.slug_column = use
      end

      def friendly
        self
      end

      def find(id)
        if is_slug?(id)
          resource = find_by(slug_column => id)
          raise ActiveRecord::RecordNotFound if resource.nil?
          resource
        else
          super
        end
      end

      def is_slug?(id)
        id.respond_to?(:to_i) && id.to_i.to_s != id.to_s
      end
    end
  end

  def to_param
    self[self.class.slug_column].to_s
  end

  private

  def generate_slug
    source = send(self.class.sluggable_attribute)
    self[self.class.slug_column] = source.present? ? source.parameterize : nil
  end
end

And then, we can include our concern in the Article class:

# app/models/article.rb
class Article < ApplicationRecord
  include FriendlyUrlable
  sluggable :title, use: :slug
end

This implementation, as simple as it currently is, fulfills the requirements we laid out above. We add some tests to make sure it works correctly:

require "test_helper"

class FriendlyUrlable < ActiveSupport::TestCase
  test "should find an article by slug" do
    article = Article.new(title: "Hello World")
    article.save
    assert_equal article, Article.friendly.find("hello-world")
  end

  test "should return 404 for non-existent slug" do
    assert_raises(ActiveRecord::RecordNotFound) do
      Article.friendly.find("non-existent-slug")
    end
  end

  test "should find an article by id when it's a string" do
    article = Article.new(title: "Hello World")
    article.save
    assert_equal article, Article.find(article.id.to_s)
  end

  test "should generate slug from title" do
    article = Article.new(title: "Hello World")
    article.save
    assert_equal "hello-world", article.slug
  end

  test "should return an article by id without using the friendly method with an integer id" do
    article = Article.new(title: "Hello World")
    article.save
    assert_equal article, Article.find(article.id)
  end

  test "the to_param method should return the slug" do
    article = Article.new(title: "Hello World")
    article.save
    assert_equal article.slug, article.to_param
  end

  test "should generate the slug from the excerpt if configured to do so" do
    Article.sluggable :excerpt, use: :slug
    article = Article.new(title: "Hello World", excerpt: "This is a test")
    article.save
    assert_equal "this-is-a-test", article.slug
  ensure
    Article.sluggable_attribute = :excerpt
    Article.slug_column = :slug
  end

  test "should not generate a slug if the sluggable attribute is nil" do
    original_sluggable_attribute = Article.sluggable_attribute
    original_slug_column = Article.slug_column

    Article.sluggable :excerpt, use: :slug
    article = Article.new(title: "Hello World", excerpt: nil)
    article.save
    assert_nil article.slug
  ensure
    Article.sluggable_attribute = original_sluggable_attribute
    Article.slug_column = original_slug_column
  end
end

This is certainly a naive implementation of what the friendly_id gem does, but it's an exercise to see how can a simple implementation be started and improved upon.

Once we saw how we can implement a very simple version of pretty URLs using a concern, let's see why using a gem is probably a good idea for your application:

Why use a gem for friendly URLs?

Being cautious and selective about the third-party code we bring into our application is a good practice. Every line of code that we didn't write is a source of potential issues and technical debt.

Libraries may not be maintained long-term so, adding them without consideration can lead to problems later.

However, there are situations when a simple feature can be solved in three lines of code, like overwriting the to_param method, but the real challenges emerge when we become aware of edge cases or features we didn't know we needed.

The friendly_id gem handles the following for us:

  • Tried and tested finders: edge cases are handled for us and a handful of finder methods are available for us.
  • Parameterization edge cases: handles the transliteration of non-Latin characters to create slugs. This can be complex to implement if your application needs to handle this use case.
  • Proper redirections for slug changes: handling redirections automatically when a slug changes is an important feature when it comes to SEO. Error statuses for previously indexed pages affect out crawl budget and can certainly affect traffic until proper re-indexation happens. So, unless you have a good reason not to, using the 'history' feature of the friendly_id gem is recommendable.
  • Slug candidates: it provides a way to generate a slug from multiple attributes.
  • Scoping: we can generate unique slugs within a scope. For example: can have two restaurants with the same name for different cities, and you might want to keep the slug clean.
  • Reserved words: allows us to configure a list of words that cannot be used as slugs. This is critical to avoid conflicts with our application routes or with names that we deem inappropriate.

Most of these features aren't rare edge cases; they're actually pretty common. Even if you don't need them now, there's a good chance you will later.

Having said this, let's explore how we can add pretty URLs to a Rails application using this gem:

Integrating the friendly_id gem into a Rails application

Adding the friendly_id gem to a Rails application is as simple as adding a model with a slug attribute and a couple of lines to the model file.

However, for this example, we will integrate the gem to fulfill a couple of requirements that are pretty common. We should:

  • Have an Article model with a title, excerpt, content, and slug attributes. It will also have an author of the class User.
  • Allow for the slug to be automatically generated from the Article title or manually generated if needed.
  • Have the slug uniquely scoped to an author. This means that two authors can have an article with the same name.
  • Implement the history feature in order to handle redirections when slugs change.

We won't be showing the User implementation because it's not really pertinent to this tutorial, but we can assume we have a User model already.

First, let's get started by creating an Article model with the required fields:

bin/rails generate model Article title:string excerpt:string content:text slug:uniq

Next, we will create a migration to add a reference to the User using an author_id field in order to make it more semantic:

bin/rails generate migration add_author_to_articles

With the corresponding migration which will add the author_id foreign key referencing the users table:

# db/migrate/#{timestamp}_add_author_to_articles
class AddAuthorToArticles < ActiveRecord::Migration[7.2]
  def change
    add_reference :articles, :author, foreign_key: { to_table: :users }
  end
end

After this migration, we add the scoped index to the slug and author_id attributes:

class UpdateArticlesSlugIndex < ActiveRecord::Migration[7.2]
  def change
    # Remove the existing unique index on slug
    remove_index :articles, :slug

    # Add a new composite index on slug and author_id
    add_index :articles, [:slug, :author_id], unique: true
  end
end

After we run this migration, we should set our associations. First for the User:

# app/models/user.rb
has_many :articles, class_name: "Article", foreign_key: :author_id

Next for the Article:

# app/models/article.rb
belongs_to :author, class_name: "User"

After we set our associations, we can add the friendly_id gem to our Gemfile and install it using Bundler:

# Add the gem to the production group
gem 'friendly_id', '~> 5.5.0'
bundle install

Now we have the friendly_id gem installed. However, we need to “install” the gem by generating its configuration file and the migration needed for the history feature:

bin/rails generate friendly_id

This command will generate a config/initializers/friendly_id.rb configuration file that allows us to personalize the gem's configuration in case we need to.

It will also add a migration that will create the friendly_id_slugs table that allows us to use the history feature of the gem.

When we proceed with the migration, the following table, which will be used to keep track of slug history and the corresponding redirections, will be added:

friendly_id_slugs table visualization

Now, we need to add the configuration to the Article model in order to make it all work:

# app/models/article.rb
class Article < ApplicationRecord
  extend FriendlyId
  friendly_id :title, use: [:slugged, :history, :scoped], scope: :author

  belongs_to :author, class_name: "User", foreign_key: :author_id

  private

  def should_generate_new_friendly_id?
    title_changed?
  end
end
  • The :title argument along with the slugged mode means that the slug will be constructed from the title attribute. And the should_generate_new_friendly_id? decides when and how the slug is constructed.
  • The history mode means that whenever the slug changes a new entry to the friendly_id_slugs table will be generated alongside with the corresponding redirection. This is very desirable from an SEO point of view: we don't need to worry about reindexing missing or changed slugs. It all happens automatically.
  • The scoped mode means that the title uniqueness would be examined along the author scope. Meaning an article title can be repeated within the articles table as long as it's not repeated for the same author.

After all this, we should be able to create a couple of users and create articles with matching titles and not have an issue because of the scopes set by the gem.

Conclusions

Pretty URLs are important for Rails applications because they make our application more user and search engine friendly.

Plus, we can also protect undesired data leaks by avoiding sequential resource creation exposure.

Adding them to a Rails application is as simple as overwriting the to_param method that every ActiveRecord instance defines.

However, a gem like friendly_id comes with a lot of added features that are useful to build applications.

Even though this is definitely not the first friendly_id tutorial using Ruby on Rails, I hope you got something out of it.

Happy coding and remember: Matz is nice, so our URLs are nice too!

Build your next rails app 10x faster with Avo

Avo dashboard showcasing data visualizations through area charts, scatterplot, bar chart, pie charts, custom cards, and others.

Find out how Avo can help you build admin experiences with Rails faster, easier and better.