Pretty URLs in Rails without a gem

Pretty URLs in Rails without a gem

Deep dive into 3 different ways in implementing pretty URLs in Rails application going from simple to advanced use cases.
Andy Duong
Andy Duong
October 01, 2018
Web

Table of Contents

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

  1. Semi-pretty URLs using integer-based IDs: /courses/1-english-for-everyone

  2. Authentic pretty URLs without integer-based IDs: /courses/english-for-everyone

  3. 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 🤘):

  1. Generating a slug to use in the URL

  2. Replace the ID by the generated slug in the Rails routing Helpers

  3. 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:

  1. Generating a slug to use in the URL and store it as an attribute in the database

  2. Replace the ID by the generated slug in the Rails routing Helpers by overriding the method to_param in the models

  3. 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.

If this is the kind of challenges you wanna tackle, Nimble is hiring awesome web and mobile developers to join our team in Bangkok, Thailand, Ho Chi Minh City, Vietnam, and Da Nang, Vietnam✌️

Join Us

Recommended Stories:

Accelerate your digital transformation.

Subscribe to our newsletter and get latest news and trends from Nimble