seo-friendly urls for your rails app with friendly_id

by Mike Zazaian at 2009-08-07 23:06:00 UTC in rails plugins

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 links

We 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.

14 comments

Jon Kinney at 2009-09-24 06:58:22 UTC

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!

Mike Zazaian at 2009-09-25 03:44:34 UTC

Jon -- I'm really glad that you enjoyed the article. Good to see you around the site.

Shaun at 2009-09-28 19:00:51 UTC

In your friendly_id example, how would would you create a friendly url based off the title AND the created-at date?

Ismael Celis at 2009-09-29 15:08:14 UTC

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}
Mike Zazaian at 2009-09-29 17:38:33 UTC

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.

Shaun at 2009-09-30 16:45:52 UTC

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!

Mike Zazaian at 2009-10-01 01:53:34 UTC

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

Shaun at 2009-10-02 16:42:44 UTC

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!

Mike Zazaian at 2009-10-02 16:57:49 UTC

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.

Shaun at 2009-10-02 18:22:07 UTC

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!

Mike Zazaian at 2009-10-02 18:50:18 UTC

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...

Josh at 2009-11-28 15:15:51 UTC

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

Voldy at 2010-04-30 14:39:37 UTC

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

Voldy at 2010-04-30 14:40:37 UTC

one remark:

map.question ':exam_id/question/:id', :controller => 'question', :action => 'show'

Comments closed

latest links

Help.GitHub - Multiple SSH keys The article from github help mirroring this process
ones zeros majors and minors ones zeros majors and minors: esoteric adventures in solipsism, by chris wanstrath
ActiveScaffold A Ruby on Rails plugin for dynamic, AJAX CRUD interfaces

login

register activate reset

feeds

articles/rss

topics

staff

editor

about

doblock focuses on ruby, rails, and all things that can help ruby and/or rails programmers hone their skills.

Techniques, tutorials, news, and even free open-source applications, doblock seeks to fill in the cracks of the ruby/rails blogosphere.

doblock v. 0.10.1 powered by Rails