Rails routing with slugs
Rails's default routing scheme is alright. /posts/1
, /posts/2
, etc. But it's not very good for SEO and it's doesn't look great to users, either. Let's change Rails's config to use a slug
that we can define ourselves.
Obviously this can be used for any model. We're just using
posts
here because it's a common use-case for Rails. I mean just look at this site.
Add the slug column to our table
First we need to generate the migration to add the column to the table, which Rails makes very easy:
$ rails g migration add_slug_to_posts slug:string
Don't migrate it just yet, though. We'll be setting Rails up to search for posts by slug
(rather than by id
), so we'll also want to add an index to the slug
column. For performance.
class AddSlugToPosts < ActiveRecord::Migration[5.0] def change add_column :posts, :slug, :string add_index :posts, :slug, unique: true end end
Now it's time to migrate.
$ rails db:migrate
Add slugs to existing posts (optional)
It may happen that you've already got posts on your site, all of which now have a nil
slug
field. Which will create problems. Let's fire up the rails console and add some post hoc slug
s.
$ rails c
Once in the console, we'll get all of your posts, iterate over them, and update the slug
. In this case we're basing the post's slug
off its title
(since it presumably has a title
).
We're using Rails's parameterize
function, which removes non-url-safe characters and adds hyphens where spaces exist. Viz. 'Sample Post No. 1'
will become 'sample-post-no-1'
Post.find_each do |post|
post.update_attributes(slug: post.title.parameterize)
end
You can swap out the hyphen for something else by passing a hash with the
separator
key toparameterize
. For example:'Sample Post No. 1'.parameterize(separator: '_')
>=> "sample_post_no_1"
Update the routes
We have to make sure to let Rails know that our routes are going to use the slug
rather than the id
. Rails, of course, has a simple way of dealing with this:
# routes.rb
resources :posts, param: :slug
Update your model
The model also has to know that we're using a slug
instead of id
as well. It's a good place to put a couple of methods for making sure that our slug
s are all going to the right place in the right shape.
We'll add some validation to make sure that our slugs are unique—no sense in having two posts with the same URL.
We're also going to do a little bit of Ruby's famous monkeypatching to change the built-in to_param
method to use our slug
when building paths, rather than the default id
.
And finally, we're going to monkeypatch the find
method to find by slug
instead of by id
. That way we can run calls like Post.find('sample-post-no-1')
.
# post.rb
validates :slug, uniqueness: true, presence: true
def to_param
slug
end
def self.find(input)
find_by_slug(input)
end
Update your controller
Now that our find
method is finding by slug
, our controller is going to run into a problem. You're probably find
ing by params[:id]
, which introduces two problems.
find
is expecting aslug
, so it's going to choke unless yourslug
is1
or55
or something, but—- You're no longer passing in a
params[:id]
, since in ourroutes.rb
we configured the route to useslug
as the param. Soparams[:id]
will always benil
.
The solution: just change the params[:id]
to params[:slug]
. So:
<h1>posts_controller.rb def show</h1> <mark>@post = Post.find(params[:slug])</mark> ... end def edit <mark>@post = Post.find(params[:slug])</mark> ... end def update <mark>@post = Post.find(params[:slug])</mark> ... end
Remember as well to add :slug
to your post_params
:
# posts_controller.rb
def hike_params
params.require(:post).permit(:title, :slug, :content)
end
Bonus round: update your already-indexed pages (optional)
It's possible that Google will have already indexed a couple of your e.g. /posts/1
, which are all now 404ing. Which means Webmaster Tools will shortly start yelling at you, which is bad not only for your SEO but also for your self esteem.
You could write up a hash or something to map each of your old id
-based post URLs to your new slug
-based ones, but I'm lazy and I just redirect them back to the posts index page.
We'll add a route matching for /posts/
followed by any numbers, using Rails's constraints
option and matching with a regex. This will catch anything like /posts/123
and /posts/999
but won't catch our new /posts/sample-post-no-1
.
Now that I think of it, I actually just covered Rails redirect with regexes like last week.
# routes.rb
get 'posts/:id', constraints: { id: /[0-9]+/ }, to: redirect('posts', status: 301)
Heads up
One thing we didn't cover here was adding a field to whatever form you're using to add new posts to handle slug
s. It's not hard to add a field for a slug
; or if you're feeling lazy, you could just write a bit of logic to generate a slug
from the title
in your posts_controller.rb
, e.g.:
@post.slug = @post.title.parameterize
Conclusion
That's really all there is to it. It's a very simple way to make your blog a little bit friendlier to search engi—I mean good old users, flocking in droves to your blog.
Next
Previous
How to redirect routes with Rails, but only if they match a given regex.