December 22, 2019

How to add FriendlyId to your Rails 6 application

Make your Ruby on Rails URLs SEO friendly with FriendlyId and a reusable concern.

It's important to have a page 'slug' that helps to describe your keywords for SEO purposes; Google and other search engines use these URLs to help determine the relevancy of your page for search terms entered by users. Score higher up the search engine result pages (SERPs) with SEO friendly URLs.

Follow this tutorial to discover how to use the dirty API changes in Rails 6 and a reusable concern for friendly page slugs. After all, it's better to keep your code tidy and best practice is to ensure you do not repeat yourself (DRY).

Before you begin

If you don't already, create a sample application. We're not going to use anything fancy and this application is just for testing. In production, you're likely to be using postgresql or something similar.

rails new slugs_are_my_new_friend

Then, enter the application folder and open Gemfile. We're going to add the FriendlyID gem as follows

gem 'friendly_id', '~> 5.3.0'

Versions are correct at the time of writing, but for newer versions, do check out the gem on GitHub.

Of course, now we've changed our Gemfile, the next step is to run bundle install:

bundle install

As the gem also requires an initializer, we should run that too:

rails generate friendly_id

To run the migration it has copied across, run:

rails db:migrate

Before using FriendlyId

Now that we have FriendlyId added to our application and its initializer, there's one more change we need make before we can use it for our controllers.

Open config/initializers/friendly_id.rb and search for 'Friendly Finders'. We're looking specifically for:

# config.use :finders

This needs to be uncommented for our purposes (and it has a slight performance boost too). Ensure your configuration looks as follows:

config.use :finders

Now, all we need to do is add our models.

Our two models are our one concern

For the purposes of this tutorial, we're going to have Pages and Articles. This is so that we can test our reusable concern is work across different models.

Creating our models

Our models should have the following fields, but do not run this just yet:

rails g model Article title summary:text content:text date:date
rails g model Page title content:text

This is before we have the fields for our friendly slugs, so we need to amend them to add two extra fields. The first field is required by FriendlyId and that's the 'slug' field. The second is an added one I like to use specifically for generating slugs separately from the title field. This is useful in the case of your about page being 'About Us' but your slug wanting to be 'find-out-more-about-company-name'. We will get on to that soon.

Let's amend our models.

rails g model Article title summary:text content:text date:date slug:uniq:index suggested_url
rails g model Page title content:text slug:uniq:index suggested_url

Now we have our two models set up with a uniqueness at the database level for our slugs and an additional field just for generating slugs, if needs be. We can now migrate our database once again:

rails db:migrate

Creating our concern

Here comes the functionable part and where our slugs must get to work. We will now create app/concerns/sluggable.rb and add in the following:

# frozen_string_literal: true

#
# Sluggable concern for generate slugs across models
#
module Sluggable
  extend ActiveSupport::Concern

  included do
    extend FriendlyId

    friendly_id :slug_candidates,
                use: %i[slugged history]

    validates :suggested_url, allow_blank: true, uniqueness: {
      case_sensitive: false,
      message: 'is already taken, leave blank to generate automatically'
    }

    #
    # Define our slug candidates for FriendlyId
    #
    # @return [array]
    #
    def slug_candidates
      [
        :suggested_url,
        :title
      ]
    end

    #
    # Determine whether a new slug should be generated depending on the fields
    # that have been changed
    #
    # @return [boolean]
    #
    def should_generate_new_friendly_id?
      !slug? || will_save_change_to_suggested_url? ||
        will_save_change_to_title?
    end
  end
end

Concerns are reusable objects that can be included across different parts of your application; you can have controller concerns or model concerns, as an example. In this instance, we are using a model concern so we can include this within our Pages and Articles models.

This is our next step; open app/models/page.rb and add:

include Sluggable

Repeat this process for app/models/article.rb, so you should have the following in your Article model:

class Article < ApplicationRecord
  include Sluggable
end

Our method of 'should_generate_new_friendly_id?' determines whether the slug should be generated. Currently, there are three separate times this could occur:

  1. If the slug is blank, generate a new slug using the 'slug_candidates'
  2. If there is a change to the title, generate a new slug using the 'slug_candidiates'
  3. If there is a change to the suggested_url, generate a new slug the 'slug_candidates'

As you might have already figured out, the slug_candidates steps through our fields in terms of:

  1. If suggested_url is present and no slug exists with this value, use this for a slug
  2. If suggested_url cannot be used for the slug, try the above but with title

This is exactly how we want this to work.

Add some content

For now, let's try these three use cases through the console. Open console by:

bundle exec rails console

We can do the following to use the suggested_url field:

Page.create!(title: 'This was created using suggested_url', suggested_url: 'A suggested URL slug')

The output from this should be

=> #<Page id: 1, title: "This was created using suggested_url", content: nil, slug: "a-suggested-url-slug", suggested_url: "A suggested URL slug", created_at: "2019-12-21 12:58:40", updated_at: "2019-12-21 12:58:40">

You will note that the slug field matches the suggested_url attribute in a more SEO friendly format.

Now, let's try one without suggested_url.

Page.create!(title: 'This is a page title')

As we'd expect, the output is:

=> #<Page id: 2, title: "This is a page title", content: nil, slug: "this-is-a-page-title", suggested_url: nil, created_at: "2019-12-21 12:59:34", updated_at: "2019-12-21 12:59:34">

Great. This suggests our code is working. Feel free to try the same with Article.

Then, exit console with:

exit

Putting our slugs in the spotlight

Currently, our slugs work hard for us behind the scenes. However, we really want them to make friends with Google, so we need to create the URLs for this.

Let's do just that for Pages.

We need to create our PagesController:

rails g controller Pages show --skip-routes

Then, in config/routes.rb, define your show action for your pages controller:

resources :pages, only: :show

Now, we need to head over to our app/controllers/pages_controller.rb and create our instance variable:

class PagesController < ApplicationController
  #
  # Our show action for pages
  #
  def show
    @page = page
  end

  private

  #
  # Our Page Active Record object
  #
  # @return [object]
  #
  def page
    Page.find(params[:id])
  end
end

You may be wondering why there is a page method. This is a good question; it's what we're going to use to ensure that our redirect can access this before passing the instance variable to the view.

But before that, let's enter app/views/pages/show.html and output our page object:

<%= debug @page %>

Let's make sure everything is right so far by running our server:

bundle exec rails server

Open http://localhost:3000/pages/1 in your browser and you should see an output.

However, our URL still contains an ID, not a slug.

To fix this, let's head over to app/controllers/application_controller.rb.

We're going to add a private method to redirect to the object's slug.

private

#
# Redirect to SEO friendly slug
#
# @param [symbol] action
# @param [object] object
#
# @return [redirect]
#
def redirect_to_slug(action:, object:)
  redirect_to action: action, id: object.friendly_id, status: 301 unless object.friendly_id == params[:id]
end

This method checks to see if the current ID matches the FriendlyID slug. If it doesn't, it redirects to the given controller action and the FriendlyID of the object. It uses a 301 redirect to tell search engines the change is permanent.

Let's pop this in our show action in app/controllers/pages_controller.rb:

#
# Our show action for pages
#
def show
  redirect_to_slug(action: :show, object: page)
  @page = page
end

Now, head back over to your web browser and refresh the page.

If all has gone well, you should see your exact same page as before, but with a friendlier slug: http://localhost:3000/pages/a-suggested-url-slug.

Have a go with Article and see if you can replicate the same thing.

As a closing tip, you don't have to do anything differently when setting up your internal routes and can still link to your objects normally. As an example, the following works as normal:

<%= link_to @page.title, @page %>

Feedback and comments

Do you like what you see? Are you now going to make sure your current and future Ruby on Rails websites are SEO optimised? Hop over to our contact page and let us know!