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 ids
are 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.
The default URL behavior in Rails
Without any customization, Rails uses the resource id
to uniquely identify it. That's why ids
are 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 id
as 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 Article
class, 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 theposts/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.
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:
- We should be able to find records by
slug
or byid
and our finder method should return an Article in both cases. - The
slug
should be generated before the record is saved to the database using an attribute we choose using thesluggable
class method. - An
ActiveRecord::RecordNotFound
error should be raised if the record isn't found. - 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 classUser
. - Allow for the
slug
to be automatically generated from theArticle
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:
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 theslugged
mode means that the slug will be constructed from thetitle
attribute. And theshould_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 thefriendly_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 thetitle
uniqueness would be examined along theauthor
scope. Meaning an articletitle
can be repeated within thearticles
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!