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:
class AddSlugToArticles < ActiveRecord::Migration[7.0]
def change
add_column :articles, :slug, :string
add_index :articles, :slug, unique: true
end
endNext, set the slug in your model:
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
endWhen 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:
class ArticlesController < ApplicationController
def show
@article = Article.find_by!(slug: params[:id])
end
endThis 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:
gem 'friendly_id', '~> 5.4.0'bundle install
rails generate friendly_id
rails db:migrateConfigure your model:
class Article < ApplicationRecord
extend FriendlyId
friendly_id :title, use: :slugged
validates :title, presence: true
endThat'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:
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:
class ArticlesController < ApplicationController
def show
@article = Article.friendly.find(params[:id])
redirect_to @article if request.path != article_path(@article)
end
endThe 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.
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
endFor scoped uniqueness—for example, slugs unique per user—use a composite index:
class AddSlugToNotes < ActiveRecord::Migration[7.0]
def change
add_column :notes, :slug, :string
add_index :notes, [:user_id, :slug], unique: true
end
endThen validate in the model:
class Note < ApplicationRecord
validates :slug, uniqueness: { scope: :user_id }
endA 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:
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
endIn your controller, check both the current slug and redirects:
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
endA 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_paramfor 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
parameterizeand 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.






Comments
Loading comments…