Tools: The SaaS Architecture Guide: How to Handle Multitenancy in Rails Routing

Tools: The SaaS Architecture Guide: How to Handle Multitenancy in Rails Routing

Source: Dev.to

Level 1: The Basics (Regex Constraints) ## Level 2: The Subdomain Strategy (Multitenancy) ## Level 3: The "Admin Gate" (Request-Based) ## Level 4: API Versioning (Header Constraints) ## Level 5: Segment Constraints (Advanced) ## Summary: The "Router First" Philosophy Most Rails developers stop learning routing after resources :posts. But if you are building a SaaS platform, an API, or a multi-tenant application, resources isn't enough. You need to control who enters your application and where they land before a Controller is even instantiated. This is the power of Routing Constraints. It is the bouncer at the door of your application. Here is how to use it like a pro. You want /products/1 to be valid, but /products/iphone-15 to be invalid (or routed differently). By default, :id accepts anything. You can restrict this using Regex. Why do this? It prevents your database from getting hit with queries that are guaranteed to fail. If a user visits /products/SELECT * FROM, the router rejects it immediately (404) without spinning up the controller. This is the most common requirement for B2B SaaS apps (e.g., slack.com vs mycompany.slack.com). You need to route the "root" domain to your marketing site, and "subdomains" to the actual app. You installed Sidekiq or GoodJob. They come with a web dashboard mounted at /sidekiq. Problem: You don't want the public to see it. Solution: Use a constraint to check the user session inside the router. Note: For this to work, the route must have access to the session middleware. In Rails 7/8 API-only mode, you might need to re-enable session middleware for this specific functionality. If you are building a mobile app backend, you will eventually break your API. You need v1 and v2 to coexist. Don't put version logic in the URL (/api/v1/...). Put it in the Headers. Why is this better? The URL stays clean (/products). The client simply changes a header to switch versions. Sometimes you want a route to match only if a certain condition in the URL is met, but it's dynamic. Example: You are building a blog platform. These look the same to the router (/:slug). You can disambiguate them with a constraint that checks against a known list. The Router is not just a map; it is a firewall. By moving logic out of your controllers and into the router, your app becomes more secure, faster, and easier to read. Do you use subdomains in your app? How do you handle local testing? (Hint: lvh.me) Let me know in the comments! 👇 Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK: # config/routes.rb # Only allow numeric IDs resources :products, constraints: { id: /\d+/ } # Allow "slugs" for this specific route get 'products/:slug', to: 'products#show', constraints: { slug: /[a-z0-9\-]+/ } Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: # config/routes.rb # Only allow numeric IDs resources :products, constraints: { id: /\d+/ } # Allow "slugs" for this specific route get 'products/:slug', to: 'products#show', constraints: { slug: /[a-z0-9\-]+/ } COMMAND_BLOCK: # config/routes.rb # Only allow numeric IDs resources :products, constraints: { id: /\d+/ } # Allow "slugs" for this specific route get 'products/:slug', to: 'products#show', constraints: { slug: /[a-z0-9\-]+/ } COMMAND_BLOCK: # config/routes.rb Rails.application.routes.draw do # 1. The Tenant App (subdomain present, and it's NOT 'www') constraints ->(req) { req.subdomain.present? && req.subdomain != "www" } do scope module: 'tenant' do root to: "dashboard#show", as: :tenant_root resources :projects end end # 2. The Marketing Site (no subdomain or 'www') root to: "pages#home" get "pricing", to: "pages#pricing" end Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: # config/routes.rb Rails.application.routes.draw do # 1. The Tenant App (subdomain present, and it's NOT 'www') constraints ->(req) { req.subdomain.present? && req.subdomain != "www" } do scope module: 'tenant' do root to: "dashboard#show", as: :tenant_root resources :projects end end # 2. The Marketing Site (no subdomain or 'www') root to: "pages#home" get "pricing", to: "pages#pricing" end COMMAND_BLOCK: # config/routes.rb Rails.application.routes.draw do # 1. The Tenant App (subdomain present, and it's NOT 'www') constraints ->(req) { req.subdomain.present? && req.subdomain != "www" } do scope module: 'tenant' do root to: "dashboard#show", as: :tenant_root resources :projects end end # 2. The Marketing Site (no subdomain or 'www') root to: "pages#home" get "pricing", to: "pages#pricing" end COMMAND_BLOCK: # config/routes.rb require 'sidekiq/web' # Define a class for the logic (Cleaner than a lambda) class AdminConstraint def matches?(request) # 1. Get the user ID from the session cookie return false unless request.session[:user_id] # 2. Check the DB (Cache this if possible!) user = User.find(request.session[:user_id]) user && user.admin? end end Rails.application.routes.draw do mount Sidekiq::Web => '/sidekiq', constraints: AdminConstraint.new end Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: # config/routes.rb require 'sidekiq/web' # Define a class for the logic (Cleaner than a lambda) class AdminConstraint def matches?(request) # 1. Get the user ID from the session cookie return false unless request.session[:user_id] # 2. Check the DB (Cache this if possible!) user = User.find(request.session[:user_id]) user && user.admin? end end Rails.application.routes.draw do mount Sidekiq::Web => '/sidekiq', constraints: AdminConstraint.new end COMMAND_BLOCK: # config/routes.rb require 'sidekiq/web' # Define a class for the logic (Cleaner than a lambda) class AdminConstraint def matches?(request) # 1. Get the user ID from the session cookie return false unless request.session[:user_id] # 2. Check the DB (Cache this if possible!) user = User.find(request.session[:user_id]) user && user.admin? end end Rails.application.routes.draw do mount Sidekiq::Web => '/sidekiq', constraints: AdminConstraint.new end COMMAND_BLOCK: # lib/constraints/api_version.rb class ApiVersion def initialize(version:, default: false) @version = version @default = default end def matches?(request) # Check for "Accept: application/vnd.myapp.v1+json" @default || request.headers['Accept']&.include?("application/vnd.myapp.#{@version}") end end Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: # lib/constraints/api_version.rb class ApiVersion def initialize(version:, default: false) @version = version @default = default end def matches?(request) # Check for "Accept: application/vnd.myapp.v1+json" @default || request.headers['Accept']&.include?("application/vnd.myapp.#{@version}") end end COMMAND_BLOCK: # lib/constraints/api_version.rb class ApiVersion def initialize(version:, default: false) @version = version @default = default end def matches?(request) # Check for "Accept: application/vnd.myapp.v1+json" @default || request.headers['Accept']&.include?("application/vnd.myapp.#{@version}") end end COMMAND_BLOCK: # config/routes.rb Rails.application.routes.draw do scope module: :v2, constraints: ApiVersion.new(version: 'v2', default: true) do resources :products end scope module: :v1, constraints: ApiVersion.new(version: 'v1', default: false) do resources :products end end Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: # config/routes.rb Rails.application.routes.draw do scope module: :v2, constraints: ApiVersion.new(version: 'v2', default: true) do resources :products end scope module: :v1, constraints: ApiVersion.new(version: 'v1', default: false) do resources :products end end COMMAND_BLOCK: # config/routes.rb Rails.application.routes.draw do scope module: :v2, constraints: ApiVersion.new(version: 'v2', default: true) do resources :products end scope module: :v1, constraints: ApiVersion.new(version: 'v1', default: false) do resources :products end end COMMAND_BLOCK: class ArchiveConstraint def matches?(request) year = request.path_parameters[:slug] year.match?(/^\d{4}$/) # Is it a 4-digit number? end end Rails.application.routes.draw do get ':slug', to: 'archives#show', constraints: ArchiveConstraint.new get ':slug', to: 'categories#show' # Fallback end Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK: class ArchiveConstraint def matches?(request) year = request.path_parameters[:slug] year.match?(/^\d{4}$/) # Is it a 4-digit number? end end Rails.application.routes.draw do get ':slug', to: 'archives#show', constraints: ArchiveConstraint.new get ':slug', to: 'categories#show' # Fallback end COMMAND_BLOCK: class ArchiveConstraint def matches?(request) year = request.path_parameters[:slug] year.match?(/^\d{4}$/) # Is it a 4-digit number? end end Rails.application.routes.draw do get ':slug', to: 'archives#show', constraints: ArchiveConstraint.new get ':slug', to: 'categories#show' # Fallback end - scope module: 'tenant': This tells Rails to look for controllers inside app/controllers/tenant/. This keeps your code clean: Tenant::DashboardController is separate from PagesController. - domain.com/rails -> Shows the "Rails" category. - domain.com/2026 -> Shows the "2026" archive. - Regex: Stop bad data early. - Subdomains: Separate your marketing site from your app logic. - Classes: Extract complex logic (Admin checks, API versions) into Ruby classes in lib/constraints.