Adding Subdomains to Rails Routing
The Rails routing system is a pretty impressive, labyrinthine piece of code. There’s a fair bit of magic going on to make your routes so easy to define and use in the rest of your Rails application. One area in which the routing system is limited however is its use of subdomains: it’s pretty much assumed that your site will be using a single, fixed domain.
There are times where it is preferable to spread a website over multiple subdomains. One common idiom in URL schemes is to separate aspects of the site under different subdomains, representative of those aspect. It many cases a simple, fixed subdomain scheme is desirable: support.whatever.com, forums.whatever.com, gallery.whatever.com and so on. On some international sites, the subdomain is used to select the language and localization: en.wikipedia.org, fr.wikipedia.org, ja.wikipedia.org. Other schemes allocate each user of the site their own subdomain, so as to personalise the experience (blogspot.com is a good example of this).
A couple of plugins currently exists for Rails developers wishing to incorporate subdomains into their routes. The de facto standard is SubdomainFu. (I’ll admit – I haven’t actually used this plugin myself.) There’s also SubdomainAccount.
I’ve recently completed work on a subdomain library which fully incorporates subdomains into the rails routing environment – in URL generation, route recognition and in route definition, something I don’t believe is currently available. As an added bonus, if offers the ability to define subdomain routes which are keyed to a model (user, category, etc.) stored in your database.
Installation
The gem is called SubdomainRoutes, and is easy to install from GitHub (you only need to add GitHub as a source once):
gem sources -a http://gems.github.com sudo gem install mholling-subdomain_routes
In your Rails app, make sure to specify the gem dependency in environment.rb:
config.gem "mholling-subdomain_routes", :lib => "subdomain_routes", :source => "http://gems.github.com"
[ UPDATE: You can also install the gem as a plugin: script/plugin install git://github.com/mholling/subdomain_routes.git ]
[ UPDATE: The gem is now hosted on Gemcutter, with slightly different installation instructions – see here. ]
(Note that the SubdomainRoutes gem requires Rails 2.2 or later to run since it changes ActionController::Resources::INHERITABLE_OPTIONS. If you’re on an older version of Rails, you need to get with the program. ;)
Finally, you’ll probably want to configure your session to work across all your subdomains. You can do this in your environment files:
# in environment/development.rb: config.action_controller.session[:session_domain] = "yourdomain.local" # or whatever # in environment/production.rb: config.action_controller.session[:session_domain] = "yourdomain.com" # or whatever
[ UPDATE: If you’re using your domain without any subdomain, you may need to set the domain to “.yourdomain.com” (with leading period). Also, you may first need to set config.action_controller.session ||= {} in your environment file, in case the session configuration variable has not already been set. ]
Mapping a Single Subdomain
Let’s start with a simple example. Say we have a site which offers a support section, where users submit and view support tickets for problems they’re having. It’d be nice to have that under a separate subdomain, say support.mysite.com. With subdomain routes we’d map that as follows:
ActionController::Routing::Routes.draw do |map| map.subdomain :support do |support| support.resources :tickets ... end end
What does this achieve? A few things. For routes defined within the subdomain block:
- named routes have a support_ prefix;
- their controllers will have a Support:: namespace;
- they will only be recognised if the host subdomain is support; and
- paths and URLs generated for them by url_for and named routes will force the support subdomain if the current host subdomain is different.
This is just what you want for a subdomain-qualified route. Rails will recognize support.mysite.com/tickets, but not www.mysite.com/tickets.
Let’s take a look at the flip-side of route recognition – path and URL generation. The subdomain restrictions are also applied here:
# when the current host is support.mysite.com: support_tickets_path => "/tickets" # when the current host is www.mysite.com: support_tickets_path => "http://support.mysite.com/tickets" # overriding the subdomain won't work: support_tickets_path(:subdomain => :www) # ActionController::RoutingError: Route failed to generate # (expected subdomain in ["support"], instead got subdomain "www")
Notice that, by necessity, requesting a path still results in an URL if the subdomain of the route is different. If you try and override the subdomain manually, you’ll get an error, because the resulting URL would be invalid and would not be recognized. This is a good thing – you don’t want to be linking to invalid URLs by mistake!
In other words, url_for and your named routes will never generate an invalid URL. This is one major benefit of the SubdomainRoutes gem: it offers a smart way of switching subdomains, requiring them to be specified manually only when absolutely necessary.
Mapping Multiple Subdomains
Subdomain routes can be set on multiple subdomains too. Let’s take another example. Say we have a review site, reviews.com, which has reviews of titles in several different media (say, DVDs, games, books and CDs). We want to key the media type to the URL subdomain, so the user knows by the URL what section of the site they’re in. (I use this scheme on my swapping site.) A subdomain route map for such a scheme could be as follows:
ActionController::Routing::Routes.draw do |map| map.subdomain :dvd, :game, :book, :cd, :name => :media do |media| media.resources :reviews ... end end
Notice that we’ve specified a generic name (media) for our subdomain, so that our namespace and named route prefix become Media:: and media_, respectively. (We could also set the :name to nil, or override :namespace or :name_prefix individually.)
Recognition of these routes will work in the same way as before. The URL dvd.reviews.com/reviews will be recognised, as will game.reviews.com/reviews, and so on. No luck with concert.reviews.com/reviews, as that subdomain is not listed in the subdomain mapping.
URL generation may behave differently however. If the URL is being generated with current host www.reviews.com, there is no way for Rails to know which of the subdomains to use, so you must specify it in the call to url_for or the named route. On the other hand, if the current host is dvd.reviews.com the URL or path will just generate with the current host unless you explicitly override the subdomain. Check it:
# when the current host is dvd.reviews.com: media_reviews_path => "/reviews" # when the current host is www.reviews.com: media_reviews_path # ActionController::RoutingError: Route failed to generate (expected # subdomain in ["dvd", "game", "book", "cd"], instead got subdomain "www") media_reviews_path(:subdomain => :book) => "http://book.reviews.com/reviews"
Again, requesting a path may result in an URL or a path, depending on whether the subdomain of the current host needs to be changed. And again, the URL-writing system will not generate any URL that will not in turn be recognised by the app.
Mapping the Nil Subdomain
SubdomainRoutes allows you to specify routes for the “nil subdomain” – for example, URLs using example.com instead of www.example.com. To do this though, you’ll need to configure the gem.
By default, SubdomainRoutes just extracts the first part of the host as the subdomain, which is fine for most situations. But in the example above, example.com would have a subdomain of example; obviously, not what you want. You can change this behaviour by setting a configuration option (you can put this in an initializer file in your Rails app):
SubdomainRoutes::Config.domain_length = 2
With this set, the subdomain for example.com will be "", the empty string. (You can also use nil to specify this in your routes.)
If you’re on a country-code top-level domain (e.g. toswap.com.au), you’d set the domain length to three. You may even need to set it to four (e.g. for nested government and education domains such as health.act.gov.au).
(Note that, in your controllers, your request will now have a subdomain method which returns the subdomain extracted in this way.)
Here’s an example of how you might want to use a nil subdomain:
ActionController::Routing::Routes.draw do |map| map.subdomain nil, :www do |www| www.resource :home ... end end
All the routes within the subdomain block will resolve under both www.example.com and just example.com.
(As an aside, this is not actually an approach I would recommend taking; you should probably not have the same content mirrored under two different URLs. Instead, set up your server to redirect to your preferred host, be it with the www or without.)
Finally, for the nil subdomain, there is some special behaviour. Specifically, the namespace and name prefix for the routes will default to the first non-nil subdomain (or to nothing if only the nil subdomain is specified). You can override this behaviour by passing a :name option.
Nested Resources under a Subdomain
REST is awesome. If you’re not using RESTful routes in your Rails apps, you should be. It offers a disciplined way to design your routes, and this flows through to the design of the rest of your app, encouraging you to capture pretty much all your application logic in models and leaving your controllers as generic and skinny as can be.
Subdomain routes work transparently with RESTful routes – any routes nested under a resource will inherit the subdomain conditions of that resource:
ActionController::Routing::Routes.draw do |map| map.subdomain :admin do |admin| admin.resources :roles, :has_many => :users ... end end
Your admin_role_users_path(@role) will automatically generate with the correct admin subdomain if required, and paths such as /roles/1/users will only be recognised when under the admin subdomain. Note that both the block form and the :has_many form of nested resources will work in this manner. (In fact, under the hood, the latter just falls through to the former.) Any other (non-resource) routes you nest under a resource will also inherit the subdomain conditions.
Setting Up Your Development Environment
To develop your app using SudomainRoutes, you’ll need to set up your machine to point some test domains to the server on your machine (i.e. to the local loopback address, 127.0.0.1). On a Mac, you can do this by editing /etc/hosts. Let’s say you want to use the subdomains www, dvd, game, book and cd, with a domain of reviews.local. Adding these lines to /etc/hosts will do the trick:
127.0.0.1 reviews.local 127.0.0.1 www.reviews.local 127.0.0.1 dvd.reviews.local 127.0.0.1 game.reviews.local 127.0.0.1 book.reviews.local 127.0.0.1 cd.reviews.local
You’ll need to flush your DNS cache for these changes to take effect:
dscacheutil -flushcache
Then fire up your script/server, point your browser to www.reviews.local:3000 and your app should be up and running. If you’re using Passenger to serve your apps in development (and I highly recommend that you do), you’ll need to add a Virtual Host to your Apache .conf file. (Don’t forget to alias all the subdomains and restart the server.)
If you’re using model-based subdomain routes (covered in my next article), you may want to use a catch-all (wildcard) subdomain. Setting this up is not so easy, since wildcards (like *.reviews.local) won’t work in your /etc/hosts file. There are a couple of work-around for this:
- Use a proxy.pac file in your browser so that it proxies *.reviews.local to localhost. How to do this will depend on the browser you’re using.
- Set up a local DNS server with an A record for the domain. This may be a bit involved.
Coming Up…
That’s all for this article. I’ve described all the functions of SubdomainRoutes which will help you easily switch between multiple, fixed subdomains in your Rails app. If all you want in your routes is to spread them between a few different subdomains, all of which you know in advance, you can stop reading here.
But, there are a few more handy methods packaged in this gem! SubdomainRoutes also offers a full complement of features for mapping subdomain routes to an ActiveRecord model. For example, you can key the subdomain to the user, so each of your site’s users gets a customised domain (like the blogs hosted on blogspot.com: postsecret.blogspot.com, fakesteve.blogspot.com, etc).
I’ll be describing how to do all this in my next article. Stay tuned!
I think the session / cookie example might be incorrect.
You have the following ...
config.action_controller.session[:session_domain] = "yourdomain.com"... do you find this actually works for you?
I usually do the folowing ...
config.action_controller.session[:session_domain] = ".yourdomain.com"... the addition of the esoteric leading "." sends cookies to *.yourdomain.com as well as yourdomain.com.
@Armando: Pretty sure I've not had any problems using just "yourdomain.com" without the leading dot. YMMV though!
This plugin is absolutely fantastic and will be heavily used where I work!
I love how the subdomains are in the routes, and are strict and enforce the subdomain to resource mapping.
Fantastic job!
Super-solid. This put an end to our "negative-routing" where we used before filters to block certain actions from being served from certain sub-domains. Now everything is explicit and lives in the routes file where it belongs.
@Josh, @Duncan: Thanks, glad you like it! By all means, spread the word. :)
Have some problems to get it work properly. I want to implement a blog under a separate subdomain on my website. Mapping all the resource-based controllers works fine, but how to route to other controllers within the blog context? (for example: blog.domain.com/index)
When I want to call these controllers I'm always getting routed the controllers in /app/controllers but not to app/controllers/blog
This is what I try to do:
What is my mistake over here?
@metafoo: Subdomain controller namespacing won't work if you're specifying the controller directly in the path, as you're doing with blog.connect ":controller/:action/:id". This is because the controller in the path overwrites any controller options. (It's not a problem with code - the same would happen with any routing which affects the namespace, e.g. map.namespace.)
My suggestion would be to rewrite your routes to avoid using the map.connect catch-all route in the first place, and to use named routes or resource routes instead. The map.connect catch-all route is a bit of an outdated Rails practice anyway. (I always remove it from my routes file!)
If you really need to keep the blog.connect route, the closest you can get is to specifiy your controllers individually as follows:
(These routes would all be namespaced under "blog/".)
Hope this helps!
Hi Matthew,
thanks for the quick answer. Routing now works fine for me. Didn't know, that the catch-all alternative isn't good practise anymore.
Great plugin! :)
Thanks for great gem. Just implemented it in my project. Problem is that project already full of controllers and views and I prefer to not move them in www directories. So, I define all my old routes in following block:
map.subdomain nil, :www, :name => nil do |www|After this route recognition don't work at all for my root routes. I get error "expected subdomain in ["", "www"], instead got subdomain" on every url helper in other subdomains, so I need to pass :subdomain => nil to them.
Is there any way to leave existing controllers in default locations but don't broke up route recognition?
@Sergey: Glad you're find the gem useful! Your problem is occurring because you've specified two subdomains (nil and :www). The url helpers will automatically switch your subdomain for you only in the case where a single subdomain is specified in the routes. Since you've specified nil and :www, the code can't infer which one you want to use and so it raises the error you're getting.
My suggestion would be to decide whether you want your canonical URL to be www.example.com or just example.com. Then specify just that canonical subdomain (i.e :www or nil) in your subdomain routes. That will solve your errors. Then, set up your server (Apache or whatever you're running) to redirect any non-canonical URLs to the canonical equivalent.
It's actually not hard to change the code to work as you're wanting it to with the [nil, :www] special case. (A couple of well-chosen modifications to the rewrite_subdomain_options method should do it.) I didn't do that though as I think it's best handled at the server level rather than the application level. Feel free to fork it and make the changes though! :)
This looks good, but how would I go about testing with subdomains using RSpec? For example, if have an users controller with routes mapped with a subdomain, how would I specify that subdomain to be used in my specs? Currently, a simple
get :indexwill find a matching route since it is using the test domain for all routing.
Oops. I meant 'will not find a matching route'. ;-)
@Matthew: You'll need to add the :subdomains option for the route you want to GET in your test. So if your routes are:
then your ItemsController spec should get the index action using:
You can see what :subdomains correspond to each route by looking at the output of rake routes.
Thanks a lot Matthew for contributing this plugin!
I could not get the session_domain config setting to work but this worked (I'm on Rails 2.3.2):
ActionController::Base.session_options[:domain] = ".mydomain.com"
In order to preserve a non-80 port number across subdomains in development I override the default_url_options method in application controller like so:
def default_url_options(options = nil)
{:port => request.port}
end
Peter
Can we achieve something like sub1.sub2.mysite.com using rails routes, with Apache?
Imran
@Imran: Certainly you can set up a server to use multi-level subdomains like that, but the Subdomain Routes gem isn't set up to handle them. It'd be a pretty rare usage I'd imagine!
Thanks a lot for your awesome work on this plugin.
In my project I'm using subdomain_routes to segment the mobile site from the regular site, is there a way to have the default paths (e.g. users_path) map to the current subdomain instead of always maping to the nil subdomain. This way I can reuse a lot of the helpers across both sites without having to manually map the correct path based on which domain the helper is being used with.
Thanks a lot for this great gem Matt. One thing that would be helpful both here and in the github readme is an explicit explanation of where subdomain controllers should be placed and how the controller classes should be defined. In other words....
app/controllers/<subdomain>/some_controller.rb
class Subdomain::SomeController < ApplicationController
This is alluded to in the docs, but not explicitly called out and it tripped me up. I eventually figured it out, but this may help others in the future.
Thanks for your awsone plugin ;)
I'd like to ask if it's possible to do something like
<code:ruby>map.with_options :path_prefix => ':lang' do |l|
l.resource :dashboard
end</code>
Thanks in advance,
Jon
You can download a full example app (with a step-by-step tutorial) showing how to implement an admin subdomain, a main domain, and multiple user subdomains using the subdomain-routes gem plus the Devise gem for authentication. Here's the link (subdomain authentication for Rails 2.3):
http://github.com/fortuity/subdomain-authentication.
Just figured it out...
<code:ruby>
map.subdomain :blog, :path_prefix => ':lang' do |blog|
blog.resources :posts
...
end
<code>
pretty simple... silly me :)