Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 074 / 100

Friendly URLs with slugs: a hands-on guide for engineers

Readable, SEO-friendly paths.

Expona Admin6 min read

SEO rankings and user experience live or die by the URLs your application serves. A path like /articles/42 tells users and search engines nothing about what they'll find; /articles/introduction-to-rails-caching does. We'll walk through friendly URLs with slugs: a hands-on guide for engineers covering everything from Rails' built-in to_param to the battle-tested friendly_id gem, unique constraints, and handling legacy URL migrations.

Why slugs matter in production

Slugs—human-readable URL segments derived from resource titles—serve multiple purposes. Search engines weight keyword-rich URLs; users bookmark and share readable paths; your analytics become more meaningful when row IDs are replaced with descriptive text. We've seen e-commerce platforms improve click-through rates simply by swapping product IDs for descriptive slugs, and content platforms benefit from SEO signals that readable paths provide.

The challenge is that slugs must remain stable. If a blog post titled "Getting Started with Redis" lives at /articles/getting-started-with-redis, changing that title (and slug) breaks existing links, search rankings, and user bookmarks. That's why we need a deliberate strategy from the start.

to_param for custom slugs

Rails provides to_param, a method that controls how a model appears in URLs. By default, it returns the record's ID; override it to return a slug instead.

Start with a migration to add a slug column:

ruby
class AddSlugToArticles < ActiveRecord::Migration[7.0]
  def change
    add_column :articles, :slug, :string
    add_index :articles, :slug, unique: true
  end
end

Next, set the slug in your model:

ruby
class Article < ApplicationRecord
  before_validation :generate_slug, on: :create
  validates :slug, presence: true, uniqueness: true

  def generate_slug
    self.slug ||= title.parameterize
  end

  def to_param
    slug
  end
end

When you call article_path(@article), Rails now passes the slug to route helpers, generating /articles/my-article-title instead of /articles/1. Your controller must change to fetch by slug:

ruby
class ArticlesController < ApplicationController
  def show
    @article = Article.find_by!(slug: params[:id])
  end
end

This approach is lightweight and gives you full control, but you're responsible for slug uniqueness, regeneration logic, and handling collisions when two articles have identical titles.

friendly_id gem for production-grade slugs

For most teams, the friendly_id gem handles the common cases—collision resolution, history tracking, and slug regeneration—so you don't reinvent the wheel.

Add it to your Gemfile and run the generator:

ruby
gem 'friendly_id', '~> 5.4.0'
ruby
bundle install
rails generate friendly_id
rails db:migrate

Configure your model:

ruby
class Article < ApplicationRecord
  extend FriendlyId
  friendly_id :title, use: :slugged

  validates :title, presence: true
end

That's it. The gem handles slug generation, uniqueness, and even creates friendly_id_slugs records for history. If you update an article's title, friendly_id redirects old slugs to the new one:

ruby
article = Article.find('getting-started-with-redis')
article.update(title: 'Redis Caching Essentials')
# Old slug 'getting-started-with-redis' still resolves, with a redirect

Your controller stays the same—find resolves both current and historical slugs:

ruby
class ArticlesController < ApplicationController
  def show
    @article = Article.friendly.find(params[:id])
    redirect_to @article if request.path != article_path(@article)
  end
end

The redirect check ensures search engines see a 301 redirect from old slugs to new ones, preserving SEO value. This pattern is proven in production SaaS and content platforms.

Unique indexes on slugs

Whether you use to_param or friendly_id, a unique index on the slug column is non-negotiable. It prevents duplicate slugs at the database level and ensures lookups are fast.

ruby
class CreateArticles < ActiveRecord::Migration[7.0]
  def change
    create_table :articles do |t|
      t.string :title, null: false
      t.string :slug, null: false
      t.text :body
      t.timestamps
    end

    add_index :articles, :slug, unique: true
  end
end

For scoped uniqueness—for example, slugs unique per user—use a composite index:

ruby
class AddSlugToNotes < ActiveRecord::Migration[7.0]
  def change
    add_column :notes, :slug, :string
    add_index :notes, [:user_id, :slug], unique: true
  end
end

Then validate in the model:

ruby
class Note < ApplicationRecord
  validates :slug, uniqueness: { scope: :user_id }
end

A trade hid: PostgreSQL and MySQL handle these indexes differently. PostgreSQL allows only one NULL value per unique index by default, which can cause issues if slug is optional during creation. Always set null: false and generate the slug early, or use a database-level check.

Redirect old slugs

In mature applications, you'll inevitably rename resources. Blog posts get retitled, products get rebranded, and you need to preserve links from external sites, emails, and social media.

If you use friendly_id with history tracking, it handles this automatically. If you're managing slugs manually with to_param, you need a redirect strategy.

One approach: store a history of previous slugs and redirect:

ruby
class ArticleSlugRedirect < ApplicationRecord
  belongs_to :article
  validates :old_slug, presence: true, uniqueness: true
end

class Article < ApplicationRecord
  has_many :slug_redirects, class_name: 'ArticleSlugRedirect', dependent: :destroy

  before_update :archive_old_slug
  
  def archive_old_slug
    if slug_changed?
      slug_redirects.create!(old_slug: slug_was)
    end
  end
end

In your controller, check both the current slug and redirects:

ruby
class ArticlesController < ApplicationController
  def show
    redirect = ArticleSlugRedirect.find_by(old_slug: params[:id])
    if redirect
      return redirect_to article_path(redirect.article), status: :moved_permanently
    end

    @article = Article.find_by!(slug: params[:id])
  end
end

A 301 (moved permanently) status tells search engines to update their index. This is critical for maintaining SEO ranking when URLs change.

Alternatively, if you use friendly_id, the gem's history table replaces this logic—no extra work needed.

Common mistakes

Slugs that change on every title edit: If you regenerate slugs without tracking history, old bookmarks and links break. Use friendly_id or store old slugs explicitly.

Forgetting the unique index: Race conditions and duplicate slugs will appear in production. Always add the index in your migration.

Using special characters in slugs: Rails' parameterize strips accents and non-ASCII characters, but if you manually build slugs, non-ASCII strings confuse browsers and break email links. Stick with alphanumerics, hyphens, and underscores.

Not redirecting old URLs: Search engines follow redirects, but users and external links see 404s. If you change slugs, redirect or use history tracking.

Case-sensitive slug lookups: URLs are case-insensitive by convention. Use find_by(slug: params[:id].downcase) or, with friendly_id, the gem handles this automatically.

Key takeaways

  • Override to_param for full control over slugs, or use friendly_id for automatic collision handling and history tracking.
  • Always add a unique index on the slug column to enforce uniqueness at the database level.
  • Generate slugs from titles using parameterize and store them immutably; change only through deliberate update logic.
  • Track or redirect old slugs when titles change, using 301 redirects to preserve SEO and user bookmarks.
  • Validate slug presence and uniqueness in the model; handle collisions with suffixes (friendly_id does this automatically).
  • Use scoped uniqueness for multi-tenant resources—slugs unique per user, per workspace, etc.
  • Test slug generation, collision resolution, and redirect behavior in your test suite before shipping to production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles