LaunchKit
← All posts
· 5 min read · by The LaunchKit team · 3 views

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.

A single URL path traced twice. On the top path, slash posts slash twelve hyphen hello hyphen world reaches find, which casts the whole segment to the integer twelve and returns post twelve without ever reading the slug, so slash posts slash twelve hyphen total hyphen nonsense returns exactly the same page with a 200. On the bottom path the controller compares the incoming segment against to_param and answers a 301 redirect to the canonical address when they differ. A side panel lists four titles and what parameterize returns for each, with the exclamation marks and the Japanese title both producing an empty string.

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.

#rails #routing #seo

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.