seo-friendly urls for your rails app with friendly_id
a quick and easy way to employ elegant, human and search engine friendly urls, while leaving plenty of room for your app to grow and change
14 comments no linksWe all know the to_param id trick for displaying human-readable article/post titles in your rails/merb app. If you don't, it looks something like this:
#!/app/models/article.rb def to_param "#{id}-#{title}".downcase.gsub(/\W+/, "-").gsub(/^[-]+|[-]$/,"").strip end
This is actually a fairly complex version of this simple convention, substituting all non-alphanumeric characters with a single hyphen, removing hyphens on either end then stripping off extraneous whitespace.
What's wonderful about it is that it still allows you easily get an active article using params[:id] in your controller, like this:
#!/app/controllers/articles_controller.rb def show @article = Article.find(params[:id]) end
Which is great and wonderful and quick, and changes your url from an ugly "http://yoursite.com/articles/33" to "http://yoursite.com/articles/33-this-is-a-really-great-title-filled-with-keywords". Which is really brilliant, because you get all of those extra keywords for SEO purposes, and people glancing at the URL for whatever reason will have some concept of what it links to rather than just showing where it came in the timeline of your site's articles.
alternatives
But what if you don't want to use the ID# at all? Unfortunately, the to_param trick doesn't work without the #{id} bit, or even if it's moved to the end of returned String, like this "#{title}-{#id}", because the String is converted to a Fixnum when it's passed into Article.find().
Okay, so that doesn't work. And the tools currently integrated into the Rails core aren't too spectacular, as this is one of the features that the core team has decided adds too much bloat (and they're probably right).
So we're left with either writing this code by hand, or finding a useful plugin to do the job for us. And while I'm normally of the "do everything yourself no matter what" kind of guy, I was super tired last night and really didn't think I could wrap my mind around a coherent system that did what I needed it to.
stringex
So I looked for plugins, and came up with a couple that seemed easy enough to implement like stringex, which actually seems to offer the most potent text-filtering of the available options, and is implemented very easily, but doesn't really have any features beyond that. So it wasn't for me.
sluggable-finder
I also looked at sluggable-finder, which doesn't offer the same intensive character-filtering that stringex did, but stores a url slug in your database to prevent it from changing if your article title changes, and other features like scoped slugs, and a reserved keywords option to prevent controller methods like /articles/feed or /articles/search from being overwritten.
My favorite part about sluggable-finder was how easy it was to change the url format using the get_slug method (in lieu of the to_param method), making it easy to define different formats for each model. However, as I said, I was super-tired when I was doing this, and had a bunch of content in my database that didn't have slugs, and couldn't figure out in my dilapidated state how to add slugs to my models after they had been created.
enter friendly_id (and/or The Dragon)
So I threw that, too, out the window, and wallowed in coffee-drunkenness and exhaustion for a moment, then came across friendly_id, which I can only describe as seo-friendly URLs the way god would code them.
Easy and powerful, if you need a url-filtering feature that friendly_id doesn't provide you're probably trying too hard.
installation
The friendly_id team suggests installing it as a gem, rather than as a plugin, which is easy enough from a terminal:
user@domain$ sudo gem update user@domain$ sudo gem install --remote friendly_id
But if you really need to install it as a plugin for whatever reason, you can:
user@domain$ cd my_app user@domain$ ./script/plugin install git://github.com/norman/friendly_id.git
Or, if you want to install it as a git submodule:
user@domain$ cd my_app user@domain$ git submodule add ./script/plugin install git://github.com/norman/friendly_id.git ./vendor/plugins/friendly_id user@domain$ git submodule init user@domain$ git submodule update
It's a little more complicated as a submodule, but it's worth the effort if you're going to be hauling your app around from server to server and don't want to have to worry about updating the plugin manually.
configuration
Okay, so we've got friendly_id installed in some form, now we need to integrate it into our rails app. Easy enough, let's just make sure we're still in our app directory --
user@domain$ cd my_app
-- then we can run the friendly_id generator (brilliant), which generates a couple of files that we'll need to use the plugin --
user@domain$ ./script/generate friendly_id
Specifically, it gives us a couple of useful Rake tasks (we'll go over these later, and a migration file that'll handle any slugs that we choose to create for our models, which looks like this:
#!/db/migrate/XXXXXXXXXXXXXX_create_slugs.rb class CreateSlugs < ActiveRecord::Migration def self.up create_table :slugs do |t| t.string :name t.integer :sluggable_id t.integer :sequence, :null => false, :default => 1 t.string :sluggable_type, :limit => 40 t.string :scope, :limit => 40 t.datetime :created_at end add_index :slugs, [:name, :sluggable_type, :scope, :sequence], :unique => true add_index :slugs, :sluggable_id end def self.down drop_table :slugs end end
Pretty cool, huh? It creates a slugs table to store all of our slugs, uses a polymorphic association, allowing us to make any of our models sluggable.
We'll get to sluggability in a moment, but first we need to integrate the migrations into our database:
user@domain$ rake db:migrate
So that's good now, we've got our 'slugs' table ready to roll. Just one last thing now to configure our app to use the gem. If you're using Rails 2.1 or higher, include the following line INSIDE of the Rails::Initializer block, like this:
#!/config/environment.rb Rails::Initializer.run do |config| # some commented out gem configs config.gem "friendly_id" # some more commented out gem configs end
Anywhere in there's fine. Just don't put it outside of that block or you'll get an error (as I initially did).
If you're using Rails 2.0 (it doesn't seem to support anything below this), then you have to require friendly_id as a gem OUTSIDE of the Rails::Initializer block, like this:
#!/config/environment.rb Rails::Initializer.run do |config| # A bunch of gem configs end require 'friendly_id'
That's where I tripped up. Either the friendly_id rdoc didn't emphasize this point enough, or I was too coffeedrunk to notice. Either way, I got it working eventually, and got to the fun part of the whole thing.
friendly_id without slugs, in its purest form
So this is easy enough. All you have to do for a non-sluggable model (something like a user object, for which every one will have a unique login and won't change much), is call the has_friendly_id method inside of the model, like this:
#!/app/models/user.rb class User < ActiveRecord::Base # friendly_id has_friendly_id :login end
Also make sure that you delete any use of the to_param method that you've got in the model, as it will overwrite the friendly_id plugin if it's placed below it.
By not using a slug (we'll get to this in a sec), we're not querying the database to get any slug values, and just transparently tell the User model that the params[:id] value in the controller will be a cleaned-up version of the :login.
So before we can use this, all we have to do is ensure that we're finding the user by params[:id] in the controller:
#!/app/controllers/users_controller.rb class UsersController < ApplicationController def show @user = User.find(params[:id]) end end
It's worth noting that using that you must use the User.find() method here. User.find_by_id() will raise an error.
If your controller already looks like this, then you're done. No changes necessary. Assuming that you've used the user route helpers built into the application, something like link_to(@user.login, @user), all of your links will automatically update, and everything should work beautifully. Your URLs will now look something like http://website.com/users/myawesomelogin, rather than http://website.com/users/32. Looking past the fact that you spent an enormous amount of money on the unimaginative domain "website.com", your urls are pretty, and the google/yahoo gods will reward you greatly for your efforts.
friendly_id with slugs
Okay, so say you've got a really great article about the role of cats in the decline of the Roman Empire. Cool, I'd probably read that article assuming it was written with tongue-in-cheek. But say also that for whatever reason you've decided that as a leader in the field of cat anarchism, you need to give all of your articles exactly the same title to drive home the relevance of your content, giving your articles a URL like http://website.com/articles/cats-played-the-harp-while-nero-burned-the-roman-empire. Weird, yes, but I appreciate the artistic decision.
Worry not, though, friendly_id has your back covered. We just have to add an option to the has_friendly_id hash in the Article model:
#!/app/models/article.rb class Article < ActiveRecord::Base # friendly_id has_friendly_id :title, :use_slug => true end
And make sure that the controller looks something like this:
#!/app/controllers/articles_controller.rb class ArticlesController < ApplicationController def show @article = Article.find(params[:id]) end end
And now, with the slug in place, we call as SUPER-USEFUL Rake task that friendly_id provides us with to create slugs for all of the existing articles in the database:
user@domain$ cd my_app user@domain$ rake friendly_id:make_slugs MODEL=Article RAILS_ENV=Production
This will spit some output into the terminal showing that indeed, each article now has its own slug in the slugs table.
So now, unlike our User model, all of our URLs will have their own slugs, and will be numerically ordered to distinguish between articles with an identical title:
http://website.com/articles/cats-played-the-harp-while-nero-burned-the-roman-empire--1
Okay, so that's pretty useful. Probably more so if your users were creating articles rather than yourself, but the utility of the :use_slug option is apparent.
This also lets us do cool stuff like versioning your friendly_id. Say, because you'd spent the last 97 hours in a university graduate library reading about the life of Nero, you wanted to use the article date for the URLs all of the sudden instead of the title, because you've learned that time is fleeting and therefore more important than words or usability. Weird, but it's your website. You paid $87,000,000 for the domain, and you should have free reign to do as you please.
class Article < ActiveRecord::User # friendly_id has_friendly_id :created_at, :use_slug => true end
Then just call the redo rake task to build slugs in the new format for all of the articles:
user@domain$ rake friendly_id:redo_slugs MODEL=Article RAILS_ENV=Production
And that'll spit some output and just like that you can see your wonderful cat articles at something like http://website.com/articles/2009-08-08-54-38-23 and variants thereof.
But say also that you've socialized your article by linking it in all of the forums in the university's Classical Civilization Studies department website, but all of the links use the old title format.
No problem. Because friendly_id keeps all of your old slugs in the slugs table, it'll recognize that the old slugs are a valid format, and still show the articles that you linked back.
Your users will, however, still be using the sane, human-readable slugs that you initially created. But you don't want that at all, people being able to access your article through legible, unartistic URLs. Whatever will we do?
enter @article.has_better_id?
has_better_id? is a really useful method that friendly_id provides to its objects, which determines whether or not there's a more current url format available for the object. For example, if you were to open up your rails console:
# open the console user@domain$ cd my_app user@domain$ ./script/console production # find the Article by numerical id >> @article = Article.find(32) >> @article.has_better_id? >> => true # or the to_param version that you once used >> @article.find("32-cats-played-the-harp-while-nero-burned-the-roman-empire") >> @article.has_better_id? >> => true # or even the prettier has_friendly_id :title that we had before >> @article.find("cats-played-the-harp-while-nero-burned-the-roman-empire--1") >> @article.has_better_id? >> => true # then finally our current slug format >> @article.find("2009-08-08-54-38-23") >> @article.has_better_id? >> => false
So what does this allow us to do? Well, it's great that we can use all those link formats, probably shouldn't be so indecisive, but change is fine. But what's really great is that if we want to enforce this new url even though there are all these old formats floating around, we can do so with a simple method:
#!/user/controllers/application_controller.rb protected def enforce_friendly_url_for(object) redirect_to object, :status => :moved_permanently if object.has_better_id? end
What it does, basically, is redirect to the newest slug/URL associated with the object if the object was found using one of the older methods. So because this is in our application controller, we can now integrate it into the process of fetching our objects, and call it in all of our model-specific controllers, like this:
#!/user/controllers/articles_controller.rb def show fetch_article do_something_with_article # or whatever your method is end protected def fetch_article @article = Article.find(params[:id]) enforce_friendly_url_for(@article) end
That's just rockin'. Now whenever somebody accesses your articles with those old URLs, it'll automatically redirect them to the most current one. And by using the :status => :moved_permanently option in our redirect_to method, even search engine spiders will recognize this change over time.
reserving urls
The absolutely last thing I'm going to cover in this article. Let's say that you changed back to the :title slug, and have two articles called "feed", or "search". Problem is, you can no longer access the respective pages you had at http://website.com/articles/feed or http://website.com/articles/search because there are articles in their place now. To prevent this, we can use the :reserved option.
It's simple enough. You call it like this:
#!/app/models/article.rb has_friendly_id :title, :use_slug => true, :reserved => ["feed","search"]
That's it. Just thought it was worth mentioning. Also worth mentioning that it automatically reserves the words "new" and "index" by default, so you don't need to worry about adding those to the array.
So that's all I'm going to cover in this article. I might write a follow-up covering the remaining features of friendly_id, but for the time being you can find documentation on the remaining features like scoped slugs, diacritics, and custom slug generation here.
no links
14 comments
Jon -- I'm really glad that you enjoyed the article. Good to see you around the site.
In your friendly_id example, how would would you create a friendly url based off the title AND the created-at date?
Hi. I'm the author of sluggable_finder. First off, friendly_id seems like the most complete (while easy to use) solution out there.
If you don't need the extra features, however, sluggable_finder will generate slugs for objects if their "slug" field is empty, so in order to generate slugs for existing records you just need to update them. If you have thousands of them you can use ActiveRecord's each method which iterates in batches of 1000
User.each{|u| u.save }
You can also do it manually:
User.each {|u| u.slug = SluggableFinder.generate(u.name);u.save}
Hi Shaun --
You should be able to use other data from your model by simply calling a block at the end of the has_friendly_id method, like this:
has_friendly_id :title, :use_slug => true, :strip_diacritics => true do |slug| slug.to_url + "_" + created_at.strftime('%d-%m-%Y') end
This would, for example, print your formatted title and append a DD-MM-YYYY format date to it with an underscore. If you wanted to do something along the lines of YYYY/MM/DD/article-title, then you'd probably have to set up some custom routes.
Hi Mike,
Thanks for the response.
I tried what you suggested, but it doesn't seem to work. :(
First I get: undefined method 'to_url'
I can remove that, but then I get: undefined local variable or method 'created_at'
Got any ideas?
Thanks!
Hey Shaun,
Sorry about that -- the to_url method was from another module that I mentioned in the article (see Stringex), and I neglected to realize that I left it in there.
Also, with the created_at method -- you should be able to change it to self.created_at.
If you want further help you can keep posting here, or sign up to the site so I can shoot you some emails.
Cheers, Mike
Hey Mike,
Getting closer, I installed Stringex, that fixed one of my problems. Thanks for that.
But now when I add self.created_at, I still get:
undefined method 'created_at' for #<Class:0x476fa58>
And my class does have created_at.
Any more ideas?
Thanks!
Hey Shaun,
A friend and I are working on this solution for you. He helped me to realise that created_at can't be called from the scope of the block (only the method) because the block is an object itself, and calling created_at from within that block was looking for the method in the block object. I think the way to do it would be either to pass created_at or self into the block as an additional block argument:
has_friendly_id :title, :use_slug => true, :strip_diacritics => true do |slug, created_at| slug.to_url + "_" + created_at.strftime('%d-%m-%Y') end
Or otherwise to directly append the created_at date to the associated slug value after and outside of the has_friendly_id block. Try the former solution first (not sure this will work, but is worth a shot) and I'll investigate the latter one after lunch. Cheers.
Ah, thanks Mike. That makes perfect sense why created_at isn't available in the block. No wonder it wouldn't work!
I tried passing created_at into the block and that doesn't work either. But your second solution did work. I created a new property for my class called friendly_title (that isn't stored in the database), that has the title and date. And then I pass that value to has_friendly_id, like this:
attr_accessible :friendly_title
has_friendly_id :friendly_title, :use_slug => true, :strip_diacritics => true
def friendly_title
self.title + "_" + created_at.strftime('%d-%m-%Y')
end
Why didn't I think of that in the first place!?! Anyway, thanks for your help!
Shaun -- great solution! Derek (mi amigo) approves. I was going to try to directly alter the Slug record that has_friendly_id creates, but I think your solution is much more elegant. Well done...
Thanks for the write-up. Im definitely going to try this out.
One thing, how does friendly_id handle MULTIPLE id params? For exam, say you are a testing site :) and have many questions for many exams. For an id-afied string might look like this:
http://www.test.com/exam/123/question/332
but instead you would prefer it to look like:
http://www.test.com/exam/dmv_written_test/question/what_should_you_do_at_a_stop_sign
so basically the route is:
http://www.test.com/exam/:exam_id/question/:id
how do you handle the :exam_id part (in this example) with friendly_id to replace it with the friendly name?
Thanks,
J
Josh, in routes.rb: map.question ':exam_id/question/:id', :controller => 'question', :action => 'index'
in your view: question_url(@exam, @question) #exactly in this order
Exam and Question models should both has_friendly_id
one remark:
map.question ':exam_id/question/:id', :controller => 'question', :action => 'show'
Wow... great writeup! I myself am a bit Diet Mt. Dew drunk tonight but this totally made sense and was engaging to read. Thanks a lot for this, I'll be implementing it tomorrow!