Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 006 / 100

Rails routing: concepts, trade-offs and production lessons

Map URLs to controllers.

Expona Admin5 min read

Rails routing: concepts, trade-offs and production lessons

A Rails application's router is the traffic controller that decides which code runs when a user hits a URL. Get it wrong, and you end up with slow lookups, confusing URLs, and maintenance headaches that compound across your codebase. We'll walk through the core routing concepts, the trade-offs each approach carries, and lessons we've learned scaling routing across e-commerce and SaaS applications handling millions of requests.

Resources — RESTful routes in one line

The resources macro is Rails' answer to boilerplate. One line generates seven standard RESTful routes: index, show, create, new, edit, update, and destroy. This convention means developers reading your code immediately understand the shape of your domain.

ruby
Rails.application.routes.draw do
  resources :products do
    resources :reviews, only: [:create, :destroy]
  end
  root "products#index"
end

This generates routes like POST /products/:product_id/reviews and DELETE /products/:product_id/reviews/:id. The nesting makes the relationship explicit: reviews belong to products.

When to use resources: Any time you're building a standard CRUD interface. E-commerce product catalogs, user management panels, configuration settings—if it has create, read, update, delete semantics, resources fits.

Trade-off — nesting depth: Nested resources become unreadable fast. A common mistake is three levels deep: /organizations/:org_id/projects/:project_id/tasks/:task_id. At that depth, URL generation becomes error-prone and your controller logic grows tangled with parent lookups. We've found that two levels works well; beyond that, switch to shallow routing.

ruby
resources :products do
  resources :reviews, shallow: true
end

With shallow: true, reviewing a single review goes to GET /reviews/:id instead of GET /products/:product_id/reviews/:id. The parent is only needed for actions that create or list within that parent.

Custom routes — member and collection routes

Not every action fits neatly into the seven RESTful verbs. Member routes act on a single resource; collection routes act on the group.

ruby
resources :products do
  member do
    patch :deactivate
    post :duplicate
  end
  collection do
    get :trending
    post :bulk_import
  end
end

This generates PATCH /products/:id/deactivate and GET /products/trending. The distinction is semantic but valuable: a team member reading the routes file immediately knows whether an action targets one resource or many.

When we've seen this go wrong: Collection routes that feel like they should be nested resources. An analytics route like GET /products/analytics is fine. But if you're returning data for a specific product with nested logic, GET /products/:id/analytics as a member route is clearer than creating a separate analytics resource. Your controller stays simpler.

Trade-off — convention versus clarity: Adding custom routes moves you away from Rails' standard seven actions. That's sometimes necessary, but it means developers must know your custom routes exist. Document them or they'll recreate them as separate resources later.

Namespaces — namespace :admin for admin areas

Admin panels, API endpoints, and internal tools live in separate namespaces. Namespaces route requests to separate controller directories and keep concerns isolated.

ruby
Rails.application.routes.draw do
  namespace :admin do
    resources :users
    resources :reports
  end
  
  namespace :api do
    namespace :v1 do
      resources :products, only: [:index, :show]
    end
  end
  
  resources :products
end

Requests to GET /admin/users route to Admin::UsersController. Requests to GET /api/v1/products route to Api::V1::ProductsController. Your public product routes sit at GET /products in ProductsController.

Real-world scenario: We manage a SaaS application where customer-facing routes and internal admin routes share the same Rails process. Namespacing the admin area meant we could add authentication middleware in one Admin::ApplicationController without touching the public product routes. Later, versioning the API was as simple as nesting Api::V1 inside Api.

Trade-off — controller organization: Each namespace requires its own controller hierarchy. For a small app, this adds directory overhead. For a growing app, it prevents the root controllers directory from becoming a dumping ground. We typically introduce namespaces once we have more than 20 controller files.

Inspect — bin/rails routes lists everything

Every route you define should be visible in one place. Run bin/rails routes to see the entire routing table.

ruby
$ bin/rails routes

                    Prefix Verb   URI Pattern
                       api GET    /api(.:format)
          admin_users      GET    /admin/users(.:format)
                           POST   /admin/users(.:format)
          admin_user       GET    /admin/users/:id(.:format)
                           PATCH  /admin/users/:id(.:format)
                           DELETE /admin/users/:id(.:format)
                  products GET    /products(.:format)
                   product GET    /products/:id(.:format)

The Prefix column is what you use in views: <%= link_to "Edit", edit_admin_user_path(@user) %>. The Prefix changes based on your route structure; knowing these prefixes by heart saves debugging time.

Production lesson: We once had a controller that generated URLs with wrong segments because a developer hadn't checked bin/rails routes after renaming a resource. The app worked locally but failed in staging when a middleware introspected the actual route names. Run bin/rails routes after any routing change to verify the prefixes are what you expect.

Filter routes to debug specific areas:

ruby
$ bin/rails routes -g admin
$ bin/rails routes -g products

Common mistakes

Mistake 1: Over-nesting resources. Three or more levels deep almost always indicates a design problem. At that depth, your URLs become brittle, your controllers fight for parent lookups, and URL generation becomes error-prone. Shallow routing or extracting a separate resource usually fixes this.

Mistake 2: Route order matters, but developers forget. Rails matches routes top-to-bottom. A catch-all route like get '/:slug', to: 'pages#show' at the top of your file will intercept everything. Put specific routes first, catch-alls last.

ruby
Rails.application.routes.draw do
  resources :products  # specific
  get '/:slug', to: 'pages#show'  # catch-all goes last
end

Mistake 3: Forgetting constraints. If you have numeric IDs, constrain them. Otherwise, a slug like trending might match /products/:id before reaching your collection route.

ruby
resources :products, constraints: { id: /\d+/ }
get '/products/:slug', to: 'products#by_slug'

Mistake 4: Inconsistent route naming. If some routes use underscores and others use hyphens, or if some are nested and others flat, maintainers get confused. Use underscores in route names and hyphens in URLs consistently.

Key takeaways

  • Start with resources. Use the seven RESTful actions as your default. They're a proven convention that makes your routes predictable.
  • Nest sparingly and shallow. Two levels of nesting is usually the limit. Beyond that, switch to shallow routing or extract a separate resource.
  • Custom routes (member and collection) are for non-CRUD actions. Use them for domain-specific operations like deactivate or trending, but document them so the next engineer knows they exist.
  • Namespaces isolate concerns. Admin panels, API versions, and internal tools belong in namespaces. This keeps your controller tree clean and makes authentication boundaries clear.
  • Always run bin/rails routes. After any routing change, verify the prefixes and URIs match what you expect. Make it part of your review checklist.
  • Route order is significant. More specific routes first, catch-alls last. Use constraints to disambiguate overlapping patterns.
  • Scale routing as your app grows. Start flat, introduce namespaces at 15–20 controllers, extract concerns as your business logic demands. Routing is the skeleton of your app; keep it clean.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles