URLs on Rails

One of the parts of rails that some people consider “ugly” and go to greath lenghts to “clean up” is the use of numeric ids in URLs: /accounts/edit/12.

URLs are considered extremely valuable real estate. Not only because users have to see them all the time, but also because search engines give them a lot of weight: since it’s a “limited resource” where you can only include a few keywords, you better use the keywords that matter most.

Rails does an excellent effort to help you use nice and clean URLs, but it stops at the :id. And it does this for one good reason. If it were to use, say, a user’s login instead of it’s numeric ID, then when a user changed his login, old URLs would no longer be valid. Yeah, I can hear you say that users don’t change logins, but what about a blog entry title? or a person’s full name? or a project name? As soon as you use some user-editable piece of information in your URLs you create the problem of state URLs, and that’s even worse than ugly URLs.

But there is a very simple solution. Use both a permanent id and a nicer textual description. Instead of an ugly /accounts/edit/12 or a perishable /accounts/edit/john-doe why not use /accounts/edit/12-john-doe. Your code has the “12″ it needs to look for the user, even if it later changed his name to “john-d-doe”, and your users and search spiders have the “john-doe” to feast on.

Implementing this is extremely simple, because Rails treats :id as a special parameter in routes. It’s specialness comes from the fact that it would try to call the to_param method on any object passed when creating URLs. That’s why url_for :id => @account is equivalent to url_for :id => @account.id, because ActiveRecord model’s have a default to_param that returns the id of the object.

All you need to do is define your own to_param for your models, and make sure you don’t explicitly include the .id in your url_fors and link_tos, because then you would be skipping your own to_param call.

class Account < ActiveRecord::Base
  def to_param
    "#{id}-#{full_name.gsub(/[^a-z1-9]+/i, '-')}"
  end
end

The second part of this solution is, of course, making sure your actions can handle these extended :ids. The smart ones amongst you would immediately think about monkey patching ActiveRecord’s find to clean up the parameters in calls like Account.find(params[:id]). But the not so stupid way would be to forget about dealing with this, giving it a try and look surprised when it works miraculosly as expected.

See, this is just a coincidence; it was not designed as part of Rails (or DHH wouldn’t have looked surprised when I brought this up at RailsConf). It’s just that Rails will pass your long :id string to the database server, which, on seeing that the id column is actually an integer, will try to convert the parameter to a number before using it, and it happens that such conversion will just use any numerical characters it finds and drop the rest, thus converting “12-john-doe” into plain 12. See, accidental behaviour over configuration; what can be better than that?

Of course, you might want to add a couple of unit tests just to make sure whatever database server you’re using behaves in this particular way. I’m not sure if that’s part of the SQL-92 standard, but I would be surprised if any major database server works differently.

So there you have it, now go add useful information to your URLs, like “asbestos-mesothelioma-canada-drugs-viagra-ambien”.

Oh, and in case you were wondering, hyphens/dashes (-) work better as word separators than any other characters. Google will match “canada drugs” against an URL like canada_drugs, but it won’t match “canada” alone. If you use hyphens, as in “canada-drugs”, then it considers them as separate, independent words.

Update: Some good points have been raised in the comments.

First, is that Aristotle Pagaltzis brought up pretty much this same argument more than six months ago.

Second, is that you might want to use redirects from any partially valid (i.e. the ID is correct, but not the rest of the slug) to the “official” URL. This is simple to implement, but requires some extra code on each controller.

Third, is that some database servers do not perform type coercion, and might get very angry if you don’t pass an integer for your id queries. Postgres was cited as an example. The solution for this is extremelly simple: just make sure this code is executed when your application starts (i.e. put it in lib and require it from environment.rb, make it a plugin, etc)

class ActiveRecord::Base
  def self.find_from_ids_with_coercion(id, options)
    find_from_ids_without_coercion(id.to_i, options)
  end
  alias_method :find_from_ids_without_coercion, :find_from_ids
  alias_method :find_from_ids, :find_from_ids_with_coercion
end

34 Responses to “URLs on Rails”

  1. topher Says:

    Hi. I learned something new.

    I have a few questions. What would happen if there are other numbers on the url? For example, 12-john-23. And wouldn’t an extra param be better, like edit/12?john-doe ?

  2. sd Says:

    The number conversion routine actually stops on the first non-numeric character. So “12-25″ is converted to “12″.

    Using an extra parameter means explicitly passing it to url_for/link_to, which would be extremelly inconvenient.

  3. josh Says:

    I’m slowly coming around to thinking this may be a good approach. It’s not “ideal”, but it’s pragmatic and seems to have some advantages. I think the biggest challenge may be getting users comforable with the change.

    By the way, have you seen this article before? It suggests exactly the same thing. http://plasmasturm.org/log/358/

  4. Phil Hagelberg Says:

    I’m on the fence about this. My biggest qualm is that if you’ve got a route like /users/12-john-doe the route /users/12-big-poop-face would also work. That seems bad.

  5. sd Says:

    Scarily similar ideas :-)

    The permanent redirect is definitelly the right thing to do if you really want to preserve the RESTfulness of your URLs.

  6. topher Says:

    sd, I got your point about the extra param. I’ll definitely try this one.

  7. josh Says:

    Phil, if you wanted to do more work, object 12 could remember its slug history and only match ones that used to be valid. What’s the downside of matching “incorrect” slugs if the ID part is correct? Worried about spiders?

  8. RSL Says:

    This is a really good idea but doesn’t catch accented characters. I’ve used gsub(/&([aeiou])[acute|grave];/, “\\1″) to catch the basic accented characters [that regex might be off, I’m going on memory] but you’ll definitely need to make your code handle accented characters.

  9. eric Says:

    If it’s really sending the whole string to the database, it’s going to fail on any database that does remotely correct type coercions, which is pretty much every one except for mysql and sqlite.

    Postgresql in particular will error out if you pass in something as innocous as 1.0 in for an integer.

  10. frank Says:

    > object 12 could remember its slug history

    A better option, imho, would be to permanently redirect all “wrong” slugs to the correct one. This is much simpler to redirect and more robust.

  11. Alex Wayne Says:

    I was following your how to on adding slugs to the id’s of records. However, I ran into a problem with your coercion to integer snippet. The problem is that the alias_method calls were trying to alias instance methods, but should have been aliasing class methods. The alias_method calls need to be in a “class

    class ActiveRecord::Base
    def self.find_from_ids_with_coercion(ids, options)
    find_from_ids_without_coercion(ids.collect(&:to_i), options)
    end

    class

  12. Alex Wayne Says:

    trying code snippet again:


    class ActiveRecord::Base
    def self.find_from_ids_with_coercion(ids, options)
    find_from_ids_without_coercion(ids.collect(&:to_i), options)
    end

    class << self
    alias_method :find_from_ids_without_coercion, :find_from_ids
    alias_method :find_from_ids, :find_from_ids_with_coercion
    end
    end

  13. Dr Nic Says:

    How about
    /forums/show/11/test-forum-for-rails instead of /forums/show/11-test-forum-for-rails?

    In the former, you get a clean :id, and you just discard the readable text?

  14. sd Says:

    the problem with something like “/forums/show/11/test-forum-for-rails” is that since the slug is a separate part, you have to include it explicitly in your url_for / link_to calls.

    So instead of just doing

    url_for :controller => “forums”, :action => “show”, :id => @forum

    you need to do

    url_for :controller => “forums”, :action => “show”, :id => @forum, :slug => @forum.slug

    thus losing all advantages provided by the to_param method.

  15. Rantings of an Unaccomplished Webmaster » Iteration 2 Development Says:

    […] the permalink coolness of this dude […]

  16. Dan Says:

    Good idea, and I’ll be using it when I rewrite my blog in Rails. For my photo gallery though, I’ve got a hierarchical structure (Folders contain Rolls which contain Photos). I wanted URLs that reflected that hierarchy, so that if a user wanted to navigate to the roll that contains a given photo she could just delete the last component of the URL. The solution I came up with is not nearly as clean as yours, but I think it’s not too bad. I’ve got a series of named routes in config/routes.rb (let’s see if I can paste code here) :

    map.folder ‘:id’, :controller => ‘folders’, :action => ’show’,
    :requirements => { :id => /\d+/ }
    map.roll ‘:folder_id/:id’, :controller => ‘rolls’, :action => ’show’,
    :requirements => { :id => /\d+/, :folder_id => /\d+/ }
    map.photo ‘:folder_id/:roll_id/:id’, :controller => ‘photos’, :action => ’show’,
    :requirements => { :id => /\d+/, :roll_id => /\d+/, :folder_id => /\d+/ }

    And a helper method in app/controllers/application.rb:

    def pretty_url_for(object, *args)
    options = pretty_url_args_to_hash(*args)

    options[:flags] ||= []
    for flag in options[:flags] do
    options[flag.to_sym] = “”
    end
    options.delete :flags

    if object.is_a?(Folder)
    return folder_url(options.merge({ :id => object.id }))
    elsif object.is_a?(Roll)
    return roll_url(options.merge({ :id => object.id,
    :folder_id => object.folder.id }))
    elsif object.is_a?(Photo)
    if options[:size].nil?
    return photo_url(options.merge({ :id => object.id,
    :roll_id => object.roll.id,
    :folder_id => object.roll.folder.id }))
    else
    return photo_raw_url(options.merge({ :id => object.id,
    :roll_id => object.roll.id,
    :folder_id => object.roll.folder.id }))
    end
    elsif object.to_sym == :root
    return url_for(options.merge({ :controller => ‘folders’,
    :action => ‘list’ }))
    else
    return url_for(options)
    end
    end

    This results in urls like http://example.com/photos/2/36/164, of which only the ‘164′ is used to find the photo in question. Not the prettiest, but better than the default, I think. I’ll see if I can rework it to use your technique to make the URL look a bit better.

  17. Tore Darell Says:

    Nice work and a good idea. I implemented this on a site I’m building and figured it would be worth it to extract it out into a plugin so I (and others) can reuse it. Thus, acts_as_sluggable was born: http://agilewebdevelopment.com/plugins/acts_as_sluggable

  18. testni_hamo2 Says:

    It doesn’t work with postgresql - therefore it’s bad idea ;))

  19. Tore Darell Says:

    It doesn’t work with PostgreSQL if you send the whole slug as an ID, but when removing it beforehand it does.

  20. Sean O'Hara Says:

    I tried to implement this on my app using mysql. I got the following error from my controller: ActiveRecord::StatementInvalid (Mysql::Error: Unknown column ‘Acid’ in ‘where clause’: SELECT id FROM artists WHERE (artists.id = 5-Acid-Mothers-Temple) LIMIT 1):

    So, I tried the fix suggested for other DBs. The one in the body of the post led to this error when booting rails:
    Exiting
    ./script/../config/../lib/sluggable.rb:5:in `alias_method’: undefined method `find_from_ids’ for class `ActiveRecord::Base’ (NameError)

    and the one suggested in the comments by Alex Wayne gave this error: Illegal instruction when I tried to load a page.

    Any ideas?

  21. Ky Le Says:

    I have 2 controller classes: Projects and Documents. I want the Documents.list to list only the documents of a particular project. So, how can I send a project’s id to Document’s methods?
    I tried to do this in the Project class:
    “:controller => ‘Document’, :action => ‘list’, :id => @project”
    And in the Documents class, I used Project.find(params[:id]) to get the project. But that didn’t work, it “couldn’t get the project without an id”!
    Please help. Thank you in advance.

  22. Ruby on Rails Says:

    This is definetely worthwhile to try out for the rails ids situation where you dont want the ugly urls. Problem is, clean URI’s could also be modeled with another method that involved using rewrites to clean up the URI’s. Just requires some dubious hacking. - ben @ http://rubyonrailsblog.com/

  23. Akhil Says:

    This is the thing was searching for.
    Thanks

  24. khang toh Says:

    I’m confused.. why u know to maintain the id in the url, you could have implemented pretty url using routing…. why do you need to go through all this? Am I missing something?

  25. Tim Connor Says:

    Sweet. As my signature link indicated, I’ve now actually taken to modifying my Typo permalinks to include the ID-title. Typo already has friendly permalinks, but this would allow for making mutable “perma”links that still worked, as you mention.

    Hell, I could probably tweak the couple filed required myself.

  26. Tim Connor Says:

    I actually ended up rolling my Typo code to do this into a plugin. Those on Typo might want to check it out: typo-permalink-with-id.

  27. Kevin Says:

    This doesn’t seem to work with rails 1.1.6 - it never calls to_param, which seems to be black-magic aliased with #id. Any ideas?

  28. Kevin Says:

    Nevermind - I had two models that were overriding each other!

  29. Matte Says:

    I think that in the regular expression there isn’t a 0.

    /[^a-z1-9]+/i

    Should be

    /[^a-z0-9]+/i

    Bye

  30. Tuniti’s Blog » Blog Archive » url’s legibles Says:

    […] ¿Y como lo he hecho? Pues primero busqué en google, y encontré este link donde explican una manera de hacerlo. La verdad que ese método no me convencia mucho y no era nada elegante, así que paré de buscar y me puse a pensar y a recordar el libro donde hablaban del tema de las routas en Rails, así que por ahi creia que estaba la solución. […]

  31. nicolas Says:

    for all this characters you don’t want in an url..
    INT = {’ÀÁÂÃÅĀĄĂ’ => ‘A’, ‘Ä’ => ‘Ae’, ‘àáâãåāąă’ => ‘a’, ‘ä’ => ‘ae’, ‘Æ’ => ‘AE’, ‘æ’ => ‘ae’, ‘ÇĆČĈĊ’ => ‘C’, ‘çćčĉċ’ => ‘c’, ‘ĎĐ’ => ‘D’, ‘ďđ’ => ‘d’, ‘ÈÉÊËĒĘĚĔĖ’ =>’E', ‘èéêëēęěĕė’ =>’e', ‘ƒ’ => ‘f’, ‘ĜĞĠĢ’ => ‘G’, ‘ĝğġģ’ => ‘g’, ‘ĤĦ’ => ‘H’, ‘ĥħ’ => ‘h’, ‘ÌÍÎÏĪĨĬĮİ’ =>’I', ‘ìíîïīĩĭįı’ =>’i', ‘IJ’ => ‘IJ’, ‘Ĵ’ => ‘J’, ‘ĵ’ => ‘j’, ‘Ķ’ => ‘K’, ‘ķĸ’ => ‘k’, ‘ŁĽĹĻĿ’ => ‘L’, ‘łľĺļŀ’ => ‘l’, ‘ÑŃŇŅŊ’ => ‘N’, ‘ñńňņʼnŋ’ => ‘n’, ‘ÒÓÔÕØŌŐŎ’ => ‘O’, ‘Ö’ => ‘Oe’, ‘òóôõøōőŏ’ => ‘o’, ‘ö’ => ‘oe’, ‘Œ’ => ‘OE’, ‘œ’ => ‘oe’, ‘ŔŘŖ’ =>’R', ‘ŕřŗ’ =>’r', ‘ŚŠŞŜȘ’ => ‘S’, ‘śšşŝș’ => ’s’, ‘ŤŢŦȚ’ => ‘T’, ‘ťţŧț’ => ‘t’, ‘ÙÚÛŪŮŰŬŨŲ’ =>’U', ‘Ü’ => ‘Ue’, ‘ùúûūůűŭũų’ =>’u', ‘ü’ => ‘ue’, ‘Ŵ’ => ‘W’, ‘ŵ’ => ‘w’, ‘ÝŶŸ’ =>’Y', ‘ýÿŷ’ =>’y', ‘ŹŽŻ’ =>’Z', ‘žżź’ =>’z'}

    def exchange_special_chars(string)
    INT.each do |key, value|
    string =string.gsub %r([#{key}]), value
    end
    string
    end

  32. Ingo Weiss Says:

    My problem is that when I use this technique, sweepers cease to expire pages correctly, since they will look to expire the un-slugged version of the page (which doesn’t exist). Has anybody else experienced this issue, and maybe found a workaround for it?

  33. Adam Says:

    instead of using the scary regexp or the |key,value| replacement, I tacked on a method to String that spits back a CGI::escape()’ed version of the string. I came from a PHP background and urlencode(); was just too handy. with the String::url_escape() method, all one needs to do in a to_param method is “#{id}-#{name.url_escape}” and all non-valid characters will be converted into their URL entities.

    To do this, I dropped the following pastie into my application.rb:

    http://pastie.textmate.org/66219

  34. fdig Says:

    map <a href="http://kPMVpfJP3CSh.nm.ru/map.html" rel="nofollow">map</a> <a href="http://kPMVpfJP3CSh.nm.ru/felice_gimondi.html" rel="nofollow">felice gimondi</a> [url=http://kPMVpfJP3CSh.nm.ru/felice_gimondi.html]felice gimondi[/url] <a href="http://kPMVpfJP3CSh.nm.ru/felice_anno_nuovo_bielorusso.html" rel="nofollow">felice anno nuovo bielorusso</a> [url=http://kPMVpfJP3CSh.nm.ru/felice_anno_nuovo_bielorusso.html]felice anno nuovo bielorusso[/url] <a href="http://kPMVpfJP3CSh.nm.ru/san_felice_del_molise_ristorante.html" rel="nofollow">san felice del molise ristorante</a> [url=http://kPMVpfJP3CSh.nm.ru/san_felice_del_molise_ristorante.html]san felice del molise ristorante[/url] <a href="http://kPMVpfJP3CSh.nm.ru/relais_chateaux_borgo_san_felice_siena.html" rel="nofollow">relais chateaux borgo san felice siena</a> [url=http://kPMVpfJP3CSh.nm.ru/relais_chateaux_borgo_san_felice_siena.html]relais chateaux borgo san felice siena[/url] <a href="http://kPMVpfJP3CSh.nm.ru/teatro_carlo_felice_genova.html" rel="nofollow">teatro carlo felice genova</a> [url=http://kPMVpfJP3CSh.nm.ru/teatro_carlo_felice_genova.html]teatro carlo felice genova[/url] <a href="http://kPMVpfJP3CSh.nm.ru/frase_biglietto_ricordo_prima_comunione.html" rel="nofollow">frase biglietto ricordo prima comunione</a> [url=http://kPMVpfJP3CSh.nm.ru/frase_biglietto_ricordo_prima_comunione.html]frase biglietto ricordo prima comunione[/url] <a href="http://kPMVpfJP3CSh.nm.ru/chianti_sina_hotel_san_felice.html" rel="nofollow">chianti sina hotel san felice</a> [url=http://kPMVpfJP3CSh.nm.ru/chianti_sina_hotel_san_felice.html]chianti sina hotel san felice[/url] <a href="http://kPMVpfJP3CSh.nm.ru/ricordo_store_milano_offerta_lavoro.html" rel="nofollow">ricordo store milano offerta lavoro</a> [url=http://kPMVpfJP3CSh.nm.ru/ricordo_store_milano_offerta_lavoro.html]ricordo store milano offerta lavoro[/url] map <a href="http://outrageoutst.krovatka.su/map.html" rel="nofollow">map</a> <a href="http://outrageoutst.krovatka.su/yurist_rabota_moskva_vakansiya.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/yurist_rabota_moskva_vakansiya.html] [/url] <a href="http://outrageoutst.krovatka.su/korr_ooo.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/korr_ooo.html] [/url] <a href="http://outrageoutst.krovatka.su/izmenenie_rinka.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/izmenenie_rinka.html] [/url] <a href="http://outrageoutst.krovatka.su/zhurnal_krilya_rodini.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/zhurnal_krilya_rodini.html] [/url] <a href="http://outrageoutst.krovatka.su/stoimost_biletov_novorossijsk.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/stoimost_biletov_novorossijsk.html] [/url] <a href="http://outrageoutst.krovatka.su/prikolnie_zaprosi_poiskovih_sistem.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/prikolnie_zaprosi_poiskovih_sistem.html] [/url] <a href="http://outrageoutst.krovatka.su/sentyabr_kalendar_sobitij.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/sentyabr_kalendar_sobitij.html] [/url] <a href="http://outrageoutst.krovatka.su/belorusskaya_obuv_moskva.html" rel="nofollow"> </a> [url=http://outrageoutst.krovatka.su/belorusskaya_obuv_moskva.html] [/url]

Leave a Reply