Pretty URLs in Rails without a gem. Illustration done by Nimble design team
Out of the box, Rails parses automatically IDs (either integer-based or UUID-based) in URLs to access resources e.g. /courses/1
. That’s how routing works by default.
But what if one of the application requirement is to have pretty URLs. So instead of /courses/1
, we would like to have courses/english-for-everyone
. So how could this be implemented with Rails?
As a first thought, we could just simply try to find a gem that could deliver this feature (a good candidate might be FriendlyId) and then just follow their documentation 😉 While to gem or not gem is an entire discussion outside of the scope of this article, there might be many valid situations when you might not want to go to the gem avenue and go the do-it-yourself road:
-
The need to fit with very custom business logic rules
-
Preference not to add an additional external dependency
-
Learning opportunity
-
…
-
You only trust the code you write yourself 😭
TL;DR
-
Semi-pretty URLs using integer-based IDs: /courses/1-english-for-everyone
-
Authentic pretty URLs without integer-based IDs: /courses/english-for-everyone
-
Supporting both non-pretty URLs and pretty URLs at the same time: /admin/courses/ and /courses/english-for-everyone
1. Semi-pretty URLs
First thing first, we need a resource we need to have access to. Let’s take a look at our fictional Course model:
# app/models/course.rb
class Course < ApplicationRecord
# == Schema Information
# Schema version: xxxx
#
# Table name: courses
# id :integer not null, primary key
# name :string(255) not null
# slug :string(255)
end
Second, let’s make a short list of the actual tasks required to implement this feature (planning rocks 🤘):
-
Generating a slug to use in the URL
-
Replace the ID by the generated slug in the Rails routing Helpers
-
Handle the request with the slug parameter in Controllers
1. Generating a slug to use in the URL
The generation process might depend on your own business logic requirements. For instance, it could be automatically created whenever a new course is added. Or, in other instance, admin users might need to add it manually. In any case, the slug needs to be stored in the database along with the other record attributes.
id: 1
name: English for Everyone
slug: english-for-everyone
2. Replace the ID by the generated slug in the Rails routing Helpers
For those who are not familiar with how routing helpers, here is a refresher on what the two methods that Rails provides out of the box for each resource:
@course = Course.first
course_path(@course) => /courses/1
course_url(@course) => http://localhost:3000/courses/1
As those helpers can be used anywhere in the application, it’s not uncommon to count their use in the 100s in a typical Rails application. Sure, you could go the manual way by trying to do a massive find of course_path(@course)
and replace everywhere iy by course_path(@course.slug)
. Sure, that would work and might require a few trials and errors. But that’s not very efficient. Also, with this solution, each time you use the helper, everyone working on the project must remember to write course_path(@course.slug)
instead of course_path(@course)
. This is bound to cause some inconsistencies or errors in the long run.
A better alternative is to override the built-in functionality in Rails. For the routing helpers, it means that we need to override the method to_param
(more info in the official: ActiveModel::Conversion#to_param):
# app/models/course.rb
class Course < ApplicationRecord
#...
def to_param
return nil unless persisted?
[id, slug].join('-') # 1-english-for-everyone
end
#...
end
Let’s add some tests for the code we have just have overridden:
# spec/models/course_spec.rb
RSpec.describe Course, type: :model do
describe '#to_param' do
context 'given a course that exists in the database' do
it 'returns the ID number join with the slug' do
course = create(:course, id: 1, slug: 'english-for-everyone')
expect { course.to_param }.to eq '1-english-for-everyone'
end
end
context 'given a course that does NOT exist in the database' do
it 'returns nil' do
course = build(:course)
expect { course.to_param }.to be_nil
end
end
end
end
3. Handle the request with the slug parameter in Controllers
Upon completing the previous step, we have successfully created semi-pretty-URL links everywhere in our application using built-in functionality. But how can the application Controllers handle these new links now?
Well, the advantage with this solution is that there is actually nothing else to do thanks to ActiveRecord 🙌
# app/controllers/courses_controller.rb
class CoursesController < ApplicationController
def show
# here params[:id] is equal 1-english-for-everyone
@course = Course.find(params[:id])
end
end
By which sorcery is this working? Well, this works because the method find
in ActiveRecord coerces its arguments to an integer using to_i
. And in Ruby, to_i
works in the way described below:
'1'.to_i => 1
'1-english-for-everyone'.to_i => 1
'1-123-english-for-everyone'.to_i => 1
Just in the previous step, let’s make sure this works by adding some tests:
# spec/controllers/courses_controller_spec.rb
RSpec.describe CoursesController, type: :controller do
describe 'GET#show' do
context 'given a slug to an existing course' do
it 'renders the show view' do
_course = create(:course, id: 1)
get :show, id: 1-english-for-everyone
expect(response).to have_http_status :ok
expect(response).to render_template :show
end
end
context 'given a slug to a course that does NOT exist' do
it 'responds with not found status' do
get :show, id: '1-non-existing-course'
expect(response).to have_http_status :not_found
end
end
end
end
The only thing we would recommend to do — if it’s not handled already in your application — is to catch not_found
exceptions in the application controller (the test above assumes this implementation):
# app/controllers/application_controller.rb
class *ApplicationController < ActionController::Base
rescue_from ActiveRecord::RecordNotFound do |_exception|
render file: 'public/404', status: :not_found
end
end
2. Authentic pretty URLs
Even though the previously explained solution is often good and simple enough for most use cases, it might not work in all situations. For instance, the application might not use integer-based IDs but instead, use UUIDs. So the type coercion made by ActiveRecord will not work. Or it might simply not be acceptable to have digits in the application URLs for SEO or “aesthetic” reasons for the application stakeholders.
So how do we go from courses/1-english-for-everyone
to courses/english-for-everyone
? We will have to go through the same set of tasks but handle some of them differently.
1. Generating a slug to use in the URL
Here nothing differs from the first solution. The hard requirement is still to save the slug for each record in the database.
2. Replace the ID by the generated slug in the Rails routing Helpers
For this task, we also need to override the method to_params
in the model. But instead of returning both ID and slug attributes, only the slug is returned.
# app/models/course.rb
class Course < ApplicationRecord
#...
def to_param
return nil unless persisted?
slug # english-for-everyone
end
#...
end
And below are the tests for this override:
# spec/models/course_spec.rb
RSpec.describe Course, type: :model do
describe '#to_param' do
context 'given a course that exists in the database' do
it 'returns the slug' do
course = create(:course, slug: 'english-for-everyone')
expect { course.to_param }.to eq 'english-for-everyone'
end
end
context 'given a course that does NOT exist in the database' do
it 'returns nil' do
course = build(:course)
expect { course.to_param }.to be_nil
end
end
end
end
3. Handle the request with the slug parameter in Controllers
That’s where most of the work is required. Now, without changing the Courses Controller, accessing the page with the pretty URL would fail miserably:
# app/controllers/courses_controller.rb
class CoursesController < ApplicationController
def show
# here params[:id] is equal english-for-everyone
@course = Course.find(params[:id]) => raises ActiveRecord::RecordNotFound error 😭
end
end
So let’s modify the method show
in the Courses Controller with the following:
# app/controllers/courses_controller.rb
class CoursesController < ApplicationController
def show
@course = Course.sluggable.find(params[:id])
end
end
But where does sluggable
come from? This is a custom class method that we define in the model Course:
# app/models/course.rb
class Course < ApplicationRecord
#...
def self.sluggable
all.extending(Sluggable::Finder)
end
#...
end
The method chains a custom module to an ActiveRecord scope.
And here is below the sluggable
module:
# app/lib/sluggable.rb
module Sluggable
module Finder
# Finds a record using the given id.
#
# If the id is "unslug", it will call the original find method.
# If the id is a numeric string like '123' it will first look for a slug
# id matching '123' and then fall back to looking for a record with the
# numeric id '123'.
#
# if the id is a numeric string like '123-foo' it
# will only search by slug id and not fall back to the regular find method.
#
# @raise ActiveRecord::RecordNotFound
def find(args)
id = args.first
return super if args.count != 1 || ::Helper.unslug?(id)
resource = resource_by_slug(id)
resource ? resource : super
end
private
def resource_by_slug(id)
resource = find_by(slug: id)
raise ActiveRecord::RecordNotFound unless resource
resource
end
end
module Helper
class << self
# True if the id is definitely slug, false if definitely unslug
#
# An object is considered "definitely unslug" if its class is or
# inherits from ActiveRecord::Base, Array, Hash, NilClass, Numeric, or
# Symbol.
#
# An object is considered "definitely slug" if it responds to +to_i+,
# and its value when cast to an integer and then back to a string is
# different from its value when merely cast to a string:
#
# slug?(123) => false
# slug?("123") => false
# slug?("abc123") => true
def slug?(arg)
false if Integer(arg)
rescue ArgumentError, TypeError
true
end
def unslug?(arg)
!slug?(arg)
end
end
end
end
The doc blocks are self explanatory but the basic gist is that the attribute slug
is used to retrieve the record from the database with the ability to detect non-integer-based slugs.
As usual, let’s add some tests to make sure the implementation works in the Courses Controller 💪:
# spec/controllers/courses_controller_spec.rb
RSpec.describe CoursesController, type: :controller do
describe 'GET#show' do
context 'given a slug to an existing course' do
it 'renders the show view' do
_course = create(:course, slug: 'english-for-everyone')
get :show, id: 'english-for-everyone'
expect(response).to have_http_status :ok
expect(response).to render_template :show
end
end
context 'given a slug to a course that does NOT exist' do
it 'responds with not found status' do
get :show, id: 'non-existing-slug'
expect(response).to have_http_status :not_found
end
end
end
end
3. Supporting both non-pretty URLs and pretty URLs at the same time
These two aforementioned solution gets you covered for most of the requirements you might face in implementing pretty URLs. But it requires to override the default behaviour of Rails. What if one of the requirement is to provide authentic pretty URLs courses/english-for-everyone
for end users but to still provide non-pretty URLs to admin users courses/1
?
In order to make this happen, we need to revisit the way we override the method to_param
(this is our task 2. Replace the ID by the generated slug in the Rails routing Helpers) by having a capability to toggle on/off the override.
# app/models/course.rb
class Course < ApplicationRecord
def self.override_to_param_enabled?
true
end
def self.toggle_override_to_param(enabled)
singleton_class.instance_eval do
define_method(:override_to_param_enabled?) do |enabled|
enabled
end
end
end
def to_param
return super unless self.class.override_to_param_enabled?
return nil unless persisted?
slug
end
end
As usual, let’s add some tests:
# spec/models/course_spec.rb
RSpec.describe Course, type: :model do
describe '#to_param' do
context 'given a course that exists in the database' do
context 'enabled override_to_param' do
it 'returns the slug' do
course = create(:course, id: 1, slug: 'english-for-everyone')
Course.toggle_override_to_param(true)
expect { course.to_param }.to eq 'english-for-everyone'
end
end
context 'disabled override_to_param' do
it 'returns the ID number' do
course = create(:course, id: 1, slug: 'english-for-everyone')
Course.toggle_override_to_param(false)
expect { course.to_param }.to eq 1
end
end
end
context 'given a course that does NOT exist in the database' do
it 'returns nil' do
course = build(:course)
expect { course.to_param }.to be_nil
end
end
end
end
Then let’s add a hook around_action
in the admin controllers where non-pretty URLs need to be used.
# app/controllers/admin/application_controller.rb
module Admin
class ApplicationController < ::ApplicationController
around_action :disable_override_to_param_course
private
def disable_override_to_param_course
Course.toggle_override_to_param(false)
yield
Course.toggle_override_to_param(true)
end
end
end
Basically, the hook disables the override thus reverting back to the default behaviour of Rails courses/1
then yields the controller action. When the latter is completed, it re-enables back our override to have pretty URLs course/english-for-everyone
. The application now supports both pretty and non-pretty URLs 💪.
Conclusion
Whichever solution you plan to implement in your application, remember that it boils down to three tasks:
-
Generating a slug to use in the URL and store it as an attribute in the database
-
Replace the ID by the generated slug in the Rails routing Helpers by overriding the method
to_param
in the models -
Handle the request with the slug parameter in Controllers
In most cases, prefer using the semi-pretty URLs courses/1-english-for-everyone
as it’s the simplest solution, almost works out of the box and is often sufficient. Only venture in the authentic pretty URLs courses/english-for-everyone
territory in specific cases when it’s a hard requirement.
URL formatting can become even more complex like supporting both pretty and non-pretty URLs in different areas of the application. Another advanced use case — that we did not cover in this post — could also be handling different slugs for each locale courses/english-for-everyone
in English but courses/anglais-pour-tout-le-monde
in French, 🙃 The sky is the limit but definitely not Rails.
Happy coding.