routing
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.
More from thinkoodle/rails-skills
minitest
Expert guidance for writing fast, maintainable Minitest tests in Rails applications. Use when writing tests, converting from RSpec, debugging test failures, improving test performance, or following testing best practices. Covers model tests, policy tests, request tests, system tests, fixtures, and TDD workflows.
32caching
Expert guidance for Rails caching — fragment caching, Russian doll caching, cache keys/versioning, low-level caching (Rails.cache), conditional GET (stale?/fresh_when), and cache stores (Solid Cache, Redis, Memcached). Use when implementing cache, caching, fragment cache, Russian doll, Rails.cache, Solid Cache, cache key, HTTP caching, stale?, fresh_when, cache store, or optimizing performance.
4uuid-primary-keys
Expert guidance for implementing UUID primary keys in Rails applications. Use when setting up UUIDs as primary keys, choosing between UUIDv4 and UUIDv7, configuring generators for UUID defaults, writing migrations with id colon uuid, adding UUID foreign keys, implementing base36 encoding for URL-friendly IDs, configuring PostgreSQL pgcrypto or gen_random_uuid, implementing SQLite binary UUID storage, choosing a primary key type, using non-sequential IDs, secure IDs, random IDs, or any ID generation strategy beyond auto-increment integers.
4security
Expert guidance for writing secure Rails applications. Use when dealing with security, CSRF protection, XSS prevention, SQL injection, authentication, authorization, sanitize, html_safe, credentials, secrets, content security policy, session security, mass assignment, strong parameters, secure headers, file uploads, open redirects, or vulnerability remediation. Covers every major attack vector and the Rails-idiomatic defenses.
4stimulus
Expert guidance for building Stimulus controllers in Rails applications. Use when creating JavaScript behaviors, writing data-controller/data-action/data-target attributes, building interactive UI components, or working with Hotwire Stimulus. Covers controller creation, targets, values, actions, classes, outlets, lifecycle callbacks, progressive enhancement, and common patterns like clipboard, flash, modal, toggle, and form validation.
4testing
Expert guidance for Rails testing infrastructure, test types, and what to test. Use when writing tests, setting up a test suite, choosing between test types, configuring system tests (Capybara), request tests, integration tests, helper tests, mailer tests, job tests, Action Cable tests, parallel testing, CI setup, test database management, or improving test coverage. Covers the test runner, fixtures vs factories, parallel testing, system tests (drivers, screenshots), request tests, controller tests (legacy), helper tests, mailer tests, job tests, Action Cable tests, test coverage, CI patterns, and test database strategies. Trigger on "test", "testing", "test suite", "system test", "request test", "integration test", "test runner", "parallel testing", "capybara", "test database", "CI testing", "test coverage".
4