Rails Routing Expert
Define clean, RESTful, minimal routes. Extra routes create unnecessary attack surface and confuse other developers reading routes.rb.
Philosophy
- RESTful by default — Use
resourcesandresource. Custom routes are a smell. - Minimal surface area — Always use
only:orexcept:to expose just what's needed. - Path helpers everywhere — Hardcoded URL strings break when routes change. Use
_pathand_urlhelpers. - Shallow nesting only — Don't nest resources more than 1 level deep; deeper nesting creates painful URLs and helper names.
- New resource > custom action — If you're adding many custom actions, you need a new controller.
When To Use This Skill
- Adding or modifying routes in
config/routes.rb - Deciding between namespace, scope, and module
- Nesting resources
- Adding member or collection routes
- Setting up API versioning
- Debugging route conflicts
- Testing routes
Instructions
Step 1: Check Existing Routes
Inspect current routes before adding new ones — duplicates and conflicts cause subtle bugs:
# Show all routes
bin/rails routes
# Filter by controller
bin/rails routes -c users
# Search by path or helper name
bin/rails routes -g admin
# Find unused routes (clean these up!)
bin/rails routes --unused
Step 2: Use Resources Correctly
resources (plural) — For collections
Generates 7 routes: index, show, new, create, edit, update, destroy.
# Generates all 7 routes — unused routes create dead endpoints and confuse devs
resources :articles
# Better — only generate what the controller actually implements
resources :articles, only: [:index, :show, :create]
Ask: which actions does this controller actually implement? Only route those.
resource (singular) — For singletons
Use when there's only ONE of something per user/context. No index action. No :id in URLs.
# Current user's profile — there's only one
resource :profile, only: [:show, :edit, :update]
# Current user's session
resource :session, only: [:new, :create, :destroy]
# App-wide dashboard
resource :dashboard, only: :show
⚠️ CRITICAL: resource (singular) still routes to a PLURAL controller.
resource :profile → ProfilesController (not ProfileController)
Path helpers are singular: profile_path (not profiles_path), edit_profile_path.
When to use singular vs plural:
- Can the user have/see multiple? →
resources(plural) - Is there exactly one in context? →
resource(singular) - Does an
indexaction make sense? →resources(plural)
Step 3: Nest Resources (Max 1 Level Deep)
Nest when a child resource only makes sense within its parent.
# GOOD — 1 level deep
resources :articles do
resources :comments, only: [:index, :create, :destroy]
end
# Avoid — 2+ levels creates unwieldy URLs and painful helper names
resources :users do
resources :articles do
resources :comments # ← /users/1/articles/2/comments/3
end
end
Use shallow nesting when child has its own identity:
# GOOD — collection routes nested, member routes flat
resources :articles do
resources :comments, shallow: true
end
# Creates:
# /articles/:article_id/comments (index, create, new)
# /comments/:id (show, edit, update, destroy)
This gives you the parent context where it matters (creating/listing) and clean URLs for direct access.
Nested path helpers include the parent:
article_comments_path(@article) # GET /articles/1/comments
article_comment_path(@article, @comment) # GET /articles/1/comments/5
# With shallow:
comment_path(@comment) # GET /comments/5
Step 4: Namespace vs Scope vs Module
These three look similar but behave differently — mixing them up causes confusing routing bugs.
namespace — Changes EVERYTHING (path + module + helpers)
namespace :admin do
resources :articles, only: [:index, :show]
end
- Path:
/admin/articles - Controller:
Admin::ArticlesController(inapp/controllers/admin/) - Helper:
admin_articles_path
Use for: Admin panels, API versions, distinct sections with their own controllers.
scope module: — Changes MODULE only (not path)
scope module: :admin do
resources :articles, only: [:index, :show]
end
- Path:
/articles(no prefix!) - Controller:
Admin::ArticlesController - Helper:
articles_path
Use for: Organizing controllers into subdirectories without changing URLs.
scope path: — Changes PATH only (not module)
scope "/admin" do
resources :articles, only: [:index, :show]
end
- Path:
/admin/articles - Controller:
ArticlesController(no module!) - Helper:
articles_path
Use for: URL prefixes without separate controller namespaces.
Quick Reference
| Method | URL prefix | Controller module | Helper prefix |
|--------|-----------|------------------|---------------|
| namespace :admin | /admin | Admin:: | admin_ |
| scope module: :admin | none | Admin:: | none |
| scope "/admin" | /admin | none | none |
When in doubt, use namespace. It's the most explicit and least surprising.
Step 5: Member vs Collection Routes
When you need routes beyond the standard 7 RESTful actions.
Member routes — Act on ONE specific record (requires :id)
resources :articles, only: [:index, :show] do
member do
patch :publish # PATCH /articles/:id/publish
patch :archive # PATCH /articles/:id/archive
end
end
Generates: publish_article_path(@article), archive_article_path(@article)
Collection routes — Act on the collection (no :id)
resources :articles, only: [:index, :show] do
collection do
get :search # GET /articles/search
get :drafts # GET /articles/drafts
delete :clear_all # DELETE /articles/clear_all
end
end
Generates: search_articles_path, drafts_articles_path
Shorthand (single route)
resources :photos do
get :preview, on: :member # GET /photos/:id/preview
get :search, on: :collection # GET /photos/search
end
⚠️ STOP — Do you actually need a custom action?
If you're adding more than 2 custom actions, you probably need a new resource:
# BAD — too many custom actions on one resource
resources :articles do
member do
patch :publish
patch :unpublish
patch :archive
patch :unarchive
patch :feature
end
end
# GOOD — extract a new resource
resources :articles, only: [:index, :show]
resources :article_publications, only: [:create, :destroy] # publish/unpublish
resources :article_archives, only: [:create, :destroy] # archive/unarchive
Step 6: Route Concerns (DRY Shared Patterns)
When multiple resources share the same nested routes:
concern :commentable do
resources :comments, only: [:index, :create, :destroy]
end
concern :taggable do
resources :tags, only: [:index, :create, :destroy]
end
resources :articles, concerns: [:commentable, :taggable]
resources :photos, concerns: [:commentable]
resources :videos, concerns: [:commentable, :taggable]
Step 7: Constraints
Segment constraints (validate params)
resources :users, constraints: { id: /\d+/ }
Request constraints (subdomain, format, etc.)
constraints subdomain: "api" do
namespace :api do
resources :articles, only: [:index, :show]
end
end
Advanced constraints (custom logic)
# Lambda
get "*path", to: "errors#not_found",
constraints: lambda { |req| !req.path.start_with?("/admin") }
# Object (responds to matches?)
class ApiConstraint
def matches?(request)
request.headers["Accept"]&.include?("application/json")
end
end
constraints ApiConstraint.new do
resources :articles, only: [:index, :show]
end
Step 8: Root Route
Always define a root route:
root "pages#home"
Namespaced roots:
namespace :admin do
root "dashboard#show" # /admin → Admin::DashboardController#show
end
Step 9: Redirects
# Simple redirect (301 by default)
get "/old-path", to: redirect("/new-path")
# With status code
get "/old-path", to: redirect("/new-path", status: 302)
# Dynamic redirect
get "/articles/:id", to: redirect("/posts/%{id}")
Step 10: Mount Engines
mount Sidekiq::Web => "/sidekiq"
mount ActionCable.server => "/cable"
Step 11: API Routes
namespace :api do
namespace :v1 do
resources :articles, only: [:index, :show, :create, :update, :destroy]
resources :users, only: [:show]
end
end
For API-only controllers, use defaults: { format: :json }:
namespace :api, defaults: { format: :json } do
namespace :v1 do
resources :articles, only: [:index, :show]
end
end
Step 12: Direct and Resolve (Custom URL Helpers)
direct — Create custom URL helpers
direct :homepage do
"https://example.com"
end
# homepage_url => "https://example.com"
direct :cdn_image do |model|
"https://cdn.example.com/#{model.image_path}"
end
# cdn_image_url(@photo) => "https://cdn.example.com/photos/1.jpg"
resolve — Customize polymorphic routing for models
Required when using resource (singular) with form_with:
resource :basket, only: [:show, :update]
resolve("Basket") { [:basket] }
# Now form_with(model: @basket) generates /basket (not /baskets/:id)
Route File Organization
Order your config/routes.rb like this:
Rails.application.routes.draw do
# 1. Root
root "pages#home"
# 2. Authentication / sessions
resource :session, only: [:new, :create, :destroy]
resources :registrations, only: [:new, :create]
# 3. Core resources (most used first)
resources :articles do
resources :comments, only: [:index, :create], shallow: true
end
# 4. Namespaced sections
namespace :admin do
root "dashboard#show"
resources :users
end
namespace :api, defaults: { format: :json } do
namespace :v1 do
resources :articles, only: [:index, :show]
end
end
# 5. Utility routes
get "up", to: "rails/health#show", as: :rails_health_check
# 6. Catch-all (if needed — put LAST)
get "*path", to: "errors#not_found"
end
Testing Routes
# test/routing/articles_routing_test.rb
class ArticlesRoutingTest < ActionDispatch::IntegrationTest
test "routes to articles#index" do
assert_routing "/articles", controller: "articles", action: "index"
end
test "routes to articles#show" do
assert_routing "/articles/1", controller: "articles", action: "show", id: "1"
end
test "generates correct path" do
assert_generates "/articles/1", controller: "articles", action: "show", id: "1"
end
test "recognizes route" do
assert_recognizes(
{ controller: "articles", action: "create" },
{ path: "/articles", method: :post }
)
end
end
Anti-Patterns
- Generating all 7 routes when you need 2 — Always use
only:orexcept: - Nesting deeper than 1 level — Flatten with shallow or separate resources
- Hardcoding paths — Hardcoded strings break when routes change; use
article_path(@article)instead of"/articles/#{@article.id}" - Using
match ... via: :all— Exposing all HTTP verbs creates security surface; be explicit - Too many custom member/collection actions — Extract a new resource instead
- Confusing namespace/scope/module — Check the table in Step 4
resourcewhen you meanresources— Singular = one thing, no index, no:id- Missing
only:/except:on nested resources — Nested routes bloat fast - Forgetting
resolvewith singularresource—form_withwill break without it - Route order bugs — More specific routes go above general ones (Rails matches top-down, first match wins)
Debugging
# See what a specific URL maps to
bin/rails routes -g "GET /articles"
# Check for conflicts
bin/rails routes | grep articles
# In rails console
Rails.application.routes.url_helpers.articles_path
app.edit_article_path(Article.first)
For detailed patterns, examples, and edge cases, see reference.md in this skill directory.