Pretty URLs in Rails, and the slug that turns out empty
Two lines turn /posts/12 into /posts/12-hello-world, and you don't need a gem for it. What you
do need is to decide what happens when a title is "!!!", when two posts are called the same
thing, and when somebody edits the slug in the address bar. Rails answers the third one in a way
that surprises most people.
All of this ran on Rails 8.1.4 in a rails new --minimal app.
The two lines
class Post < ApplicationRecord
before_validation { self.slug ||= title.to_s.parameterize }
def to_param = "#{id}-#{slug}"
end
to_param is what every URL helper calls to turn a record into a path segment, so overriding it
is enough. post_path(post) is now /posts/12-hello-world, link_to post.title, post is too, and
you didn't touch the routes file or a single controller.
The controller doesn't change either, and that's the part worth looking at.
find accepts anything after the id
Post.find(post.to_param) # => the post
post.to_param.to_i # => 12
Post.find("12-total-nonsense") # => the post
find casts its argument to an integer, "12-total-nonsense".to_i is 12, and you get post 12.
So the slug half of the URL is never read, never compared, and never validated.
That's convenient and it has a cost you should take a position on. Your article is now reachable
at /posts/12-anything-somebody-types, every one of those URLs returns 200 with identical content,
and anyone can link to your page under a slug of their choosing. For most apps that's a shrug. If
you care about canonical URLs, two things fix it: put a <link rel="canonical"> on the page, or
redirect when the slug doesn't match.
def show
@post = Post.find(params[:id])
redirect_to(@post, status: :moved_permanently) and return if params[:id] != @post.to_param
end
That second version also gives you free slug history. Change a title, the old URL still finds the post by id, and the visitor is bounced to the new address with a 301.
parameterize hands back an empty string
This is the failure that reaches production, because it needs a title nobody on the team would think to type.
| Title | parameterize |
|---|---|
"Hello World" |
"hello-world" |
"Rails 8.1: what's new?" |
"rails-8-1-what-s-new" |
"Café crème à Paris" |
"cafe-creme-a-paris" |
"Ruby/Rails & Co." |
"ruby-rails-co" |
"!!!" |
"" |
"---" |
"" |
"日本語のタイトル" |
"" |
Accents transliterate properly and punctuation collapses the way you'd hope. But anything with no ASCII letters in it comes back empty, and that includes every title in Japanese, Chinese, Korean, Arabic, Greek or Hebrew.
An empty slug gives you /posts/12-, which still works because of the to_i behaviour above, so
nothing breaks loudly. What you get instead is a site where every non-Latin title shares the slug
"", which is fine right up until you decide to look posts up by slug alone.
It doesn't truncate, either. We parameterized a 300 character title and got a 299 character slug, and that goes straight into a URL.
Both are worth one line each:
before_validation do
self.slug ||= title.to_s.parameterize.presence&.first(60) || "post"
end
presence turns the empty string into nil so the fallback fires, and first(60) keeps the URL
readable. Pick your own fallback. The id is already in the path, so it only has to be tidy.
Nothing is unique until you say so
Post.create!(title: "Same", slug: "same")
Post.create!(title: "Same", slug: "same")
Post.where(slug: "same").count # => 2
No error. find_by(slug: "same") then returns the lower id, silently and forever. If your slugs
are decoration on top of an id this genuinely doesn't matter. If you ever plan to drop the id
prefix, it matters enormously and the time to add the index is before there are duplicates in the
table, not after.
A slug-only lookup is at least honest about misses. find_by!(slug: "nope") raises
ActiveRecord::RecordNotFound, which your app already renders as a 404, while find_by returns
nil and hands you a NoMethodError further down the view.
What this page doesn't cover
No gem. FriendlyId does slug history, sluggable candidates and per-scope uniqueness properly, and if you need a slug-only URL with redirects from every previous slug then that's a real feature set rather than a wrapper. Everything above is what you're choosing between.
Nothing here measures what any of this does to your indexes. A slug-only lookup wants an index on
the column and we never added one, so there's no number in this article about what find_by(slug:)
costs on a table with a million rows against find on the primary key.
Routing constraints are absent too. The path segment is wide open above, and a constraints block
on the route is where you'd narrow it if you wanted /posts/12-hello to 404 rather than redirect.
Comments
No comments yet. Be the first.