Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Ruby on Rails  Module 3 – Routing and controllers

Routing: resources and RESTful URLs

Map URLs to Rails 8.1 controller actions with resources, shallow nesting, namespaces and constraints, read bin/rails routes, and build links with helpers.

  • Beginner
  • 10 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

What you will learn

  • Map URLs to actions with resources, singular resources, nested and shallow routes, namespaces and scopes
  • Read bin/rails routes and predict the helper, verb, path and action of a route
  • Order routes and add constraints so that a general route does not take a specific path
  • Add member and collection routes, concerns, redirects and a root route
  • Choose between path and URL helpers, and set the host that URL helpers need outside a request

Before you start

What you’ll learn · Before you start

What you will learn

  • Map URLs to actions with resources, singular resources, nested and shallow routes, namespaces and scopes
  • Read bin/rails routes and predict the helper, verb, path and action of a route
  • Order routes and add constraints so that a general route does not take a specific path
  • Add member and collection routes, concerns, redirects and a root route
  • Choose between path and URL helpers, and set the host that URL helpers need outside a request

Before you start

On this page

One line of a Rails routes file, resources :batches, declared eight routes to seven actions in the table this page records, and every request an app answers is compared with a list like that, from the top, until a route fits. Put two lines in the wrong order and GET /reports/monthly reaches the show action with "monthly" as its id, as one of the runs below shows.

config/routes.rb is where those decisions live. This page reads the routes file of Kaksha, the coaching-centre app this track builds, one declaration at a time: resources and singular resources, nested and shallow routes, namespaces and scopes, the first-match rule and constraints, member and collection routes, the routes that are not resources, and the helpers that build every URL from the same declarations, so no view or mailer writes one by hand.

One line of resources declares seven routes

Here is Kaksha’s routes file, the one every run on this page uses:

config/routes.rb: Kaksha's routes for this lesson Ruby · kaksha/config/routes.rb
Rails.application.routes.draw do
  # The page most visitors ask for comes first.
  root "batches#index"

  # These two lines were written by bin/rails generate authentication.
  resource :session
  resources :passwords, param: :token

  # Reviews hang off two resources, so the nested routes are declared once.
  concern :reviewable do
    resources :reviews, only: [:index, :create]
  end

  resources :teachers, only: [:index, :show], concerns: :reviewable

  resources :batches, concerns: :reviewable do
    resources :enrollments, shallow: true
  end

  resources :payments, only: [:index, :show] do
    member do
      post :refund
    end
    collection do
      get :overdue
    end
  end

  namespace :admin do
    resources :fee_plans
  end

  scope "(:locale)", locale: /en|hi/ do
    resources :notices, only: [:index, :show]
  end

  # Old links from the course pages of the first version keep working.
  get "/courses/:id", to: redirect("/batches/%{id}")

  # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
  # Can be used by load balancers and uptime monitors to verify that the app is live.
  get "up" => "rails/health#show", as: :rails_health_check
end

A resourceful route: A set of routes declared by one resources line (seven actions on four URL shapes) or one singular resource line (no index and no id), each pairing an HTTP verb and a path with a controller action. such as resources :batches declares one route for each of seven actions, index, show, new, create, edit, update and destroy, on four URL shapes: /batches, /batches/new, /batches/:id and /batches/:id/edit. The verb tells the shapes apart, so GET /batches lists the batches while POST /batches creates one. The router: The part of Rails that compares a request's method and path with config/routes.rb, takes the first route that matches and calls its controller action. More in “How Rails handles a request” keeps every route in a list, in the order the file declares them, and bin/rails routes prints that list:

bin/rails routes: batches, nested enrollments, reviews and sign-in Ruby · sessions/routes_session.rb
# Kaksha's routes as bin/rails routes prints them. Each command boots the app once and lists the routes that
# config/routes.rb declares, in the order of the file: -c keeps the routes of one controller, -g those whose helper,
# verb, path or action contains the pattern.
require_relative "session"

Session.run(<<~SH)
  cd ../kaksha
  # One line, resources :batches, and the root that points at its index.
  bin/rails routes -c batches
  # Enrollments nested with shallow: true, and the reviews that the reviewable concern adds to two resources.
  bin/rails routes -g "enrollment|review"
  # The two lines bin/rails generate authentication wrote: a singular resource, and a resource keyed by a token.
  bin/rails routes -g "session|password"
SH

Output

$ cd ../kaksha
# One line, resources :batches, and the root that points at its index.
$ bin/rails routes -c batches
    Prefix Verb   URI Pattern                 Controller#Action
      root GET    /                           batches#index
   batches GET    /batches(.:format)          batches#index
           POST   /batches(.:format)          batches#create
 new_batch GET    /batches/new(.:format)      batches#new
edit_batch GET    /batches/:id/edit(.:format) batches#edit
     batch GET    /batches/:id(.:format)      batches#show
           PATCH  /batches/:id(.:format)      batches#update
           PUT    /batches/:id(.:format)      batches#update
           DELETE /batches/:id(.:format)      batches#destroy
# Enrollments nested with shallow: true, and the reviews that the reviewable concern adds to two resources.
$ bin/rails routes -g "enrollment|review"
              Prefix Verb   URI Pattern                                  Controller#Action
     teacher_reviews GET    /teachers/:teacher_id/reviews(.:format)      reviews#index
                     POST   /teachers/:teacher_id/reviews(.:format)      reviews#create
   batch_enrollments GET    /batches/:batch_id/enrollments(.:format)     enrollments#index
                     POST   /batches/:batch_id/enrollments(.:format)     enrollments#create
new_batch_enrollment GET    /batches/:batch_id/enrollments/new(.:format) enrollments#new
     edit_enrollment GET    /enrollments/:id/edit(.:format)              enrollments#edit
          enrollment GET    /enrollments/:id(.:format)                   enrollments#show
                     PATCH  /enrollments/:id(.:format)                   enrollments#update
                     PUT    /enrollments/:id(.:format)                   enrollments#update
                     DELETE /enrollments/:id(.:format)                   enrollments#destroy
       batch_reviews GET    /batches/:batch_id/reviews(.:format)         reviews#index
                     POST   /batches/:batch_id/reviews(.:format)         reviews#create
# The two lines bin/rails generate authentication wrote: a singular resource, and a resource keyed by a token.
$ bin/rails routes -g "session|password"
       Prefix Verb   URI Pattern                      Controller#Action
  new_session GET    /session/new(.:format)           sessions#new
 edit_session GET    /session/edit(.:format)          sessions#edit
      session GET    /session(.:format)               sessions#show
              PATCH  /session(.:format)               sessions#update
              PUT    /session(.:format)               sessions#update
              DELETE /session(.:format)               sessions#destroy
              POST   /session(.:format)               sessions#create
    passwords GET    /passwords(.:format)             passwords#index
              POST   /passwords(.:format)             passwords#create
 new_password GET    /passwords/new(.:format)         passwords#new
edit_password GET    /passwords/:token/edit(.:format) passwords#edit
     password GET    /passwords/:token(.:format)      passwords#show
              PATCH  /passwords/:token(.:format)      passwords#update
              PUT    /passwords/:token(.:format)      passwords#update
              DELETE /passwords/:token(.:format)      passwords#destroy

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby routes_session.rb

Read the first table column by column. The prefix is the name of the route, and adding _path or _url to it gives its helper: batch becomes batch_path. A blank prefix shares the name of the row above it. resources :batches has eight rows, not seven, because update answers two verbs, PATCH and PUT. The row above them is root, which also reaches batches#index.

Write every route out

A route that reads the controller or the action out of the URL, such as get ":controller(/:action(/:id))", has been deprecated since Rails 5.0, and on the main branch of Rails drawing one raises ArgumentError. List the routes the app serves instead.

The third table holds the two lines that bin/rails generate authentication wrote, as the Security guide’s list of generated files also shows. resource :session, singular, has no index and no :id segment, because a browser has one session and /session is enough to name it. resources :passwords, param: :token names its segment :token instead of :id, which is how a password-reset link carries its token.

Routes for actions that do not exist

The generated SessionsController defines three actions, new, create and destroy, yet resource :session declared seven routes, show, edit and update among them. Writing resource :session, only: %i[new create destroy] makes the table match the controller; the Routing guide adds that dropping unused routes saves memory and routing time.

Checkpoint

Check: What resources and resource declare

  1. Question 1 of 2 bin/rails routes -c batches printed nine rows: root, then eight rows for resources :batches. How many different controller actions do those eight rows reach?

    Type a number.

    A hint

    Two of the eight rows point at the same action.

    Show the answer: Question 1 of 2

    Answer: 7 actions

    The eight rows reach index, create, new, edit, show, update and destroy. update appears twice, once for PATCH and once for PUT, which is why the table has one row more than there are actions.

  2. Question 2 of 2 The authentication generator wrote resource :session. Which request signs a visitor out?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: DELETE /session

    resource :session declares routes on /session itself: GET /session/new for the form, POST /session to sign in and DELETE /session to sign out. There is no index and no id, because each browser has exactly one session.

Nesting puts the parent in the URL, and shallow takes it out again

Every enrollment belongs to one batch, so Kaksha declares resources :enrollments inside resources :batches. The enrollments of batch 7 then live at /batches/7/enrollments, and the action reads the batch from params[:batch_id], taken from the URL, rather than from a hidden form field that a visitor could edit. The Routing guide’s rule of thumb is to nest one level deep at most, because each level adds a segment to every URL and an argument to every helper.

Nested in full, all eight enrollment routes would carry the batch, although an enrollment’s own id already finds it. shallow: true keeps the parent only where the action needs it. Step through what the option did to Kaksha’s table:

Interactive · step by step What shallow nesting and a concern do to Kaksha's routes Step through the routes the two declarations add, and where each one keeps or drops the parent.
What shallow nesting and a concern do to Kaksha's routes, step 5 of 5. Showing resources :batches, enrollments, shallow, /batches/:batch_id/…, /enrollments/:id, concern :reviewable, …/:batch_id/reviews. In focus: …/:batch_id/reviews.resources :batchesthe parentenrollments, shallowdeclared inside it/batches/:batch_id/…index, new, create/enrollments/:idshow, edit, update, destroyconcern :reviewablereviews: index, create…/:batch_id/reviewsand …/:teacher_id/reviews

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. A resource inside a resource. Every enrollment belongs to one batch, so Kaksha declares resources :enrollments inside resources :batches. Nested in full, all eight enrollment routes would start with /batches/:batch_id.
  2. The list keeps its parent. With shallow: true, the three routes that work on the enrollments of one batch stay nested: index, new and create. The recorded table lists them under /batches/:batch_id/enrollments.
  3. One record drops it. The routes that act on one enrollment (show, edit, update by PATCH or PUT, destroy) need only its own id, so they move to /enrollments/:id and lose the batch_ prefix of their helpers.
  4. Helpers follow the URL. batch_enrollments_path(7) gave /batches/7/enrollments and enrollment_path(31) gave /enrollments/31 in the helpers run: a nested helper takes the parent, a shallow one the record alone.
  5. A concern shares nested routes. concern :reviewable declares reviews once, and concerns: :reviewable adds index and create under teachers and under batches, so the table lists teacher_reviews and batch_reviews.

The second table of the session above is that split, recorded: index, new and create under /batches/:batch_id/enrollments, and edit, show, update and destroy at /enrollments/:id. A shallow route: A route of a nested resource that leaves the parent out of the URL once the record has its own id, so /enrollments/31 serves show, edit, update and destroy while the list stays under its batch. like /enrollments/31 is enough for one record, and its batch is one association away.

The same table lists reviews twice, under teachers and under batches. Both come from one routing concern: A named block of routes declared once with concern and added to several resources with concerns:, so routes that more than one resource needs, such as nested reviews, are written in one place.: concern :reviewable declares the nested reviews once, and concerns: :reviewable adds them to each resource that should have them, so two copies of one block can never drift apart.

Key idea

Keep the parent in the URL where an action lists or creates children, and let shallow: true give every route for one existing record its own short URL.

Checkpoint

Check: Nested and shallow routes, and concerns

  1. Question 1 of 2 resources :enrollments, shallow: true sits inside resources :batches. Which helper builds the edit page of enrollment 31?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: edit_enrollment_path(31)

    Shallow nesting keeps index, new and create under the parent and moves show, edit, update and destroy to the child's own URL. The edit row of the recorded table is edit_enrollment GET /enrollments/:id/edit.

  2. Question 2 of 2 concern :reviewable declares resources :reviews, only: [:index, :create], and two resources use it. How many review rows did bin/rails routes print?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 4 rows

    The concern adds two routes, index and create, under each resource that names it: teacher_reviews GET and POST, batch_reviews GET and POST. Two resources times two routes gives the four rows of the recorded table.

namespace moves the controller too, scope moves only the path

Kaksha’s fee plans are for staff, not for students, so their routes sit inside namespace :admin. A namespace changes three things at once: the path gains /admin, the helpers gain admin_, and the controller moves into the Admin module. admin/fee_plans#index is Admin::FeePlansController, in app/controllers/admin/fee_plans_controller.rb, a class of its own that a check in an Admin base controller can guard along with every other staff page.

Notices are read in English or Hindi, at /en/notices and /hi/notices, or at /notices in the default language. That is scope "(:locale)", locale: /en|hi/: the brackets make the segment optional, the regular expression allows two values, and nothing else moves. The helper stays notices_path, the controller stays NoticesController, and the action reads the language from params[:locale]. The first table of the second session shows both declarations:

bin/rails routes: admin namespace, locale scope, payments, redirect Ruby · sessions/more_routes_session.rb
# The rest of Kaksha's routes file: a namespace and a scope, which move a resource to another path, and the extra
# actions that member and collection add to payments. The last command lists the redirect for old course links.
require_relative "session"

Session.run(<<~SH)
  cd ../kaksha
  # namespace :admin moves path, helper and controller; scope "(:locale)" moves only the path.
  bin/rails routes -g "admin|notice"
  # member adds an action on one payment, collection an action on the list.
  bin/rails routes -g "payment|courses"
SH

Output

$ cd ../kaksha
# namespace :admin moves path, helper and controller; scope "(:locale)" moves only the path.
$ bin/rails routes -g "admin|notice"
             Prefix Verb   URI Pattern                         Controller#Action
    admin_fee_plans GET    /admin/fee_plans(.:format)          admin/fee_plans#index
                    POST   /admin/fee_plans(.:format)          admin/fee_plans#create
 new_admin_fee_plan GET    /admin/fee_plans/new(.:format)      admin/fee_plans#new
edit_admin_fee_plan GET    /admin/fee_plans/:id/edit(.:format) admin/fee_plans#edit
     admin_fee_plan GET    /admin/fee_plans/:id(.:format)      admin/fee_plans#show
                    PATCH  /admin/fee_plans/:id(.:format)      admin/fee_plans#update
                    PUT    /admin/fee_plans/:id(.:format)      admin/fee_plans#update
                    DELETE /admin/fee_plans/:id(.:format)      admin/fee_plans#destroy
            notices GET    (/:locale)/notices(.:format)        notices#index {locale: /en|hi/}
             notice GET    (/:locale)/notices/:id(.:format)    notices#show {locale: /en|hi/}
# member adds an action on one payment, collection an action on the list.
$ bin/rails routes -g "payment|courses"
          Prefix Verb URI Pattern                    Controller#Action
  refund_payment POST /payments/:id/refund(.:format) payments#refund
overdue_payments GET  /payments/overdue(.:format)    payments#overdue
        payments GET  /payments(.:format)            payments#index
         payment GET  /payments/:id(.:format)        payments#show
                 GET  /courses/:id(.:format)         redirect(301, /batches/%{id})

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby more_routes_session.rb

Side by side, the two declarations differ in exactly the columns a namespace moves:

Declaration Path Helper Controller
namespace :admin /admin/fee_plans admin_fee_plans_path Admin::FeePlansController
scope "(:locale)" (/:locale)/notices notices_path NoticesController

Choosing between the two

Use a namespace when a part of the app has controllers of its own, such as staff pages behind their own check. Use a scope when only the URL changes, such as a language prefix.

Checkpoint

Check: What a namespace and a scope move

  1. Question 1 of 2 resources :fee_plans sits inside namespace :admin. Which class must app/controllers/admin/fee_plans_controller.rb define?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Admin::FeePlansController

    namespace :admin changes the path (/admin/fee_plans), the helpers (admin_fee_plans_path) and the controller (Admin::FeePlansController in app/controllers/admin/). Zeitwerk expects the class that matches that file path.

  2. Question 2 of 2 The notices sit inside scope "(:locale)", locale: /en|hi/. Which of these paths reach notices#index?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • /notices
    • /hi/notices

    The table printed (/:locale)/notices with the constraint locale: /en|hi/. The segment may be left out, or it may be en or hi. The controller and the helper are the same in every case; only the path changes.

The first route that fits wins, and constraints decide what fits

The router reads its list from the top and stops at the first route whose verb, path and constraints all fit the request. The Routing guide makes the point with a custom route written below resources: the show route above it wins. Follow GET /reports/monthly down such a file:

Interactive · stage by stage How the router picks a route for GET /reports/monthly Step down the routes with the request until one fits, then see how each fix changes which route fits first.
How the router picks a route for GET /reports/monthly, step 8 of 8. Showing GET /reports/monthly, index: GET /reports (no: path ends at /reports), new: GET /reports/new (no: the word is new), edit: …/:id/edit (no: needs /edit at the end), show: GET /reports/:id (fix 2: id must be digits), get reports/monthly (reached once show refuses). In focus: show: GET /reports/:id, get reports/monthly.first fitGET /reports/monthlythe requestindex: GET /reportsno: path ends at /reportsnew: GET /reports/newno: the word is newedit: …/:id/editno: needs /edit at the endshow: GET /reports/:idfix 2: id must be digitsget reports/monthlyreached once show refuses

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. The request arrives. GET /reports/monthly reaches the router. It holds the routes in the order the file declares them and takes the first one whose verb, path and constraints all fit.
  2. index does not fit. resources :reports declared index first, as GET /reports, and create second, which wants POST. A GET with a second segment fits neither, so the router reads on.
  3. new does not fit. new is GET /reports/new. Its second segment is the literal word new, and monthly is a different word.
  4. edit does not fit. edit is GET /reports/:id/edit. The :id segment would take monthly, but the path would then need /edit after it, and this request ends there.
  5. show fits, and the search stops. show is GET /reports/:id, and :id accepts any one segment. monthly fills it, so the router calls reports#show with params[:id] = "monthly", as set 1 of the run printed.
  6. The custom route is never read. get "reports/monthly" sits below resources, so no request for /reports/monthly ever reaches it. Set 1 also sent /reports/june to show, with id june.
  7. Fix 1: declare it first. Declared above resources, the custom route is compared first and fits exactly, so set 2 reached reports#monthly. /reports/7 and /reports/june still went to show.
  8. Fix 2: a constraint on :id. Set 3 keeps the order and adds constraints: { id: /\d+/ }. show now refuses monthly, the router reads on and the custom route fits. /reports/june fits nothing: a RoutingError, which a running app answers with 404.

The program behind the figure draws three route sets and asks each where four paths go. recognize_path answers the way the router would, without a server:

Four paths through three orders of the same routes Ruby · first_match.rb
# The router compares a request with the routes in the order the file declares them and takes the first that fits.
# Three route sets answer the same four paths: a custom route below resources, the same route above it, and the route
# below again with a constraint on :id. recognize_path asks a route set which action a path reaches; it needs no
# server, only the controller class to exist.
require "action_controller/railtie"

class ReportsController < ActionController::Base; end
class DashboardsController < ActionController::Base; end

def router(&routes)
  set = ActionDispatch::Routing::RouteSet.new
  set.draw(&routes)
  set
end

def reach(set, url)
  set.recognize_path(url).map { |key, value| "#{key}: #{value.inspect}" }.join(", ")
rescue ActionController::RoutingError => error
  "#{error.class.name.demodulize}: #{error.message}"
end

sets = {
  "1. get 'reports/monthly' below resources :reports" => router do
    resources :reports
    get "reports/monthly", to: "reports#monthly"
  end,
  "2. get 'reports/monthly' above resources :reports" => router do
    get "reports/monthly", to: "reports#monthly"
    resources :reports
  end,
  "3. below again, with constraints: { id: /\\d+/ }" => router do
    resources :reports, constraints: { id: /\d+/ }
    get "reports/monthly", to: "reports#monthly"
  end
}

sets.each do |name, set|
  puts name
  %w[/reports/monthly /reports/7 /reports/new /reports/june].each do |path|
    puts "  GET #{path.ljust(16)} -> #{reach(set, path)}"
  end
end

# A lambda constraint receives the whole request, so it can test what a path cannot show, such as the host.
centres = router do
  get "dashboard", to: "dashboards#show", constraints: ->(request) { request.subdomain.present? }
end
puts "4. a lambda constraint: only a centre's own subdomain"
%w[http://pune.kaksha.example/dashboard http://kaksha.example/dashboard].each do |url|
  puts "  GET #{url.ljust(37)} -> #{reach(centres, url)}"
end

Output

1. get 'reports/monthly' below resources :reports
  GET /reports/monthly -> controller: "reports", action: "show", id: "monthly"
  GET /reports/7       -> controller: "reports", action: "show", id: "7"
  GET /reports/new     -> controller: "reports", action: "new"
  GET /reports/june    -> controller: "reports", action: "show", id: "june"
2. get 'reports/monthly' above resources :reports
  GET /reports/monthly -> controller: "reports", action: "monthly"
  GET /reports/7       -> controller: "reports", action: "show", id: "7"
  GET /reports/new     -> controller: "reports", action: "new"
  GET /reports/june    -> controller: "reports", action: "show", id: "june"
3. below again, with constraints: { id: /\d+/ }
  GET /reports/monthly -> controller: "reports", action: "monthly"
  GET /reports/7       -> controller: "reports", action: "show", id: "7"
  GET /reports/new     -> controller: "reports", action: "new"
  GET /reports/june    -> RoutingError: No route matches "/reports/june"
4. a lambda constraint: only a centre's own subdomain
  GET http://pune.kaksha.example/dashboard  -> controller: "dashboards", action: "show"
  GET http://kaksha.example/dashboard       -> RoutingError: No route matches "http://kaksha.example/dashboard"

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby first_match.rb

Set 1 sent /reports/monthly to show with id "monthly", because :id accepts any segment. Set 2 declared the custom route first and reached reports#monthly. Set 3 kept the order and added a route constraint: A test a route adds to its verb and path: a regular expression for one segment, or a lambda or object for the whole request. The route fits only when the test passes, so the router moves on otherwise., constraints: { id: /\d+/ }, so show refuses every id that is not digits: /reports/monthly fell through to the custom route, and /reports/june fitted nothing at all. A running app answers that RoutingError with 404, as the Configuring guide’s table of exception statuses lists.

A regular expression tests one segment. A lambda receives the whole request, so it can test what no path shows: in the fourth part of the run, only pune.kaksha.example, a centre’s own subdomain, reached the dashboard. The same order rule is why the guide puts root at the top of the file, so the page most visitors ask for is matched first.

Fixing the order and stopping there

Moving the custom route up works until someone adds the next one below resources. A constraint writes the rule into the route itself, so prefer it when ids are numbers, and keep a routing test that asserts where /reports/monthly goes.

Checkpoint

Check: First match, order and constraints

  1. Question 1 of 2 A routes file declares resources :reports and then get "reports/monthly", to: "reports#monthly". Where does GET /reports/monthly go?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: reports#show, with params[:id] = "monthly"

    The router compares the routes in the order the file declares them and stops at the first fit. show, GET /reports/:id, comes before the custom route, and "monthly" is a valid value for :id.

  2. Question 2 of 2 Set 3 adds constraints: { id: /\d+/ } to resources :reports and keeps get "reports/monthly" below it. What does GET /reports/june get?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No route matches, which a running app answers with 404

    A constraint is part of what has to fit. With id limited to digits, /reports/7 still reaches show and /reports/monthly falls through to the custom route, but /reports/june fits no route at all.

Member and collection routes add actions, sparingly

Payments need two actions that the seven do not cover: refunding one payment and listing the overdue ones. A member route acts on one record and a collection route on the set, and each adds a route and a helper:

Declared inside resources :payments Route and helper Acts on
member do post :refund end POST /payments/:id/refund, refund_payment_path(payment) one payment
collection do get :overdue end GET /payments/overdue, overdue_payments_path the whole list

The second table of the session above lists both rows, ahead of index and show, because resources runs its block before it adds its own routes.

Choose the verb by what the action does. A refund changes data, so it is POST; the overdue list only reads, so it is GET. HTTP defines GET as a safe method: following a link, prefetching or reloading may repeat it, and nothing should change on the server when that happens.

Many extra actions hide a resource

The Routing guide’s advice, when a resource keeps gaining extra actions, is to ask whether another resource is hiding in them. If refunds grow states and a history of their own, resources :refunds nested under payments gives them index, create and show, and PaymentsController stays small.

Checkpoint

Check: Member and collection routes

  1. Question 1 of 2 Inside resources :payments, member do post :refund end declares which route?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: POST /payments/:id/refund, helper refund_payment_path

    member do ... end adds routes on /payments/:id/<action>, collection do ... end on /payments/<action>. The helper puts the action before the resource name: refund_payment_path(payment), overdue_payments_path.

  2. Question 2 of 2 Refunds of payments start to need a reason, states of their own and a history, each with its own pages. What does the Routing guide's advice point to?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A refunds resource nested under payments

    When a resource keeps gaining verbs, give the noun its own resource. resources :refunds under payments gives refunds index, create and show, with a controller of their own.

Routes that are not resources: root, redirects and /up

Three lines of Kaksha’s file declare no resource. root "batches#index" names what / shows. get "/courses/:id", to: redirect("/batches/%{id}") keeps the links of an older version of Kaksha working by sending them to the new URL. get "up" => "rails/health#show" is the health check every new Rails app gets: its generated comment says /up answers 200 when the app boots without exceptions and 500 otherwise, for load balancers and uptime monitors.

A route set is itself a Rack application, so this program hands it requests directly, with no server, and prints what came back:

The root, two redirects and the health check, answering requests Ruby · redirects_and_root.rb
# A route set is a Rack application: hand it a request and it answers, so this program needs no server. It draws the
# routes of Kaksha that are not resources (the root, two redirects and the health check of every new app) and sends
# each one a GET, printing the status, the Location header of a redirect and what the body holds.
require "action_controller/railtie"

class BatchesController < ActionController::Base
  def index = render(plain: "the batch list")
  def show = render(plain: "batch #{params[:id]}")
end

routes = ActionDispatch::Routing::RouteSet.new
helpers = routes.url_helpers
routes.default_url_options = { host: "kaksha.example" }
puts "before root is drawn: root_url defined? #{helpers.method_defined?(:root_url)}"

routes.draw do
  root "batches#index"
  resources :batches, only: [:index, :show]
  get "/courses/:id", to: redirect("/batches/%{id}")
  get "/fees", to: redirect("/payments", status: 302)
  get "up" => "rails/health#show", as: :rails_health_check
end
puts "after root is drawn:  root_url defined? #{helpers.method_defined?(:root_url)} -> #{helpers.root_url}"
puts

%w[/ /courses/7 /courses/7?page=2 /fees /up].each do |path|
  status, headers, body = routes.call(Rack::MockRequest.env_for("http://kaksha.example#{path}"))
  text = +""
  body.each { |part| text << part }
  body.close if body.respond_to?(:close)
  line = "GET #{path.ljust(17)} #{status}"
  line << "  location: #{headers['location']}" if headers["location"]
  if text.empty?
    # a redirect carries no body worth showing
  elsif headers["content-type"].to_s.start_with?("text/html")
    line << "  an HTML page of #{text.bytesize} bytes"
  else
    line << "  body: #{text.inspect}"
  end
  puts line
end

Output

before root is drawn: root_url defined? false
after root is drawn:  root_url defined? true -> http://kaksha.example/

GET /                 200  body: "the batch list"
GET /courses/7        301  location: http://kaksha.example/batches/7
GET /courses/7?page=2 301  location: http://kaksha.example/batches/7
GET /fees             302  location: http://kaksha.example/payments
GET /up               200  an HTML page of 73 bytes

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby redirects_and_root.rb

The course link answered 301 Moved Permanently, the default of redirect, and the Routing guide warns that browsers and proxies may cache a 301, so keep it for a move you will not undo. The fee link, which the program declares with status: 302, answered 302. The second course request also shows that its query string was dropped: %{id} rebuilt the path from the path parameters alone, so a redirect that must keep ?page=2 takes a block, which receives the request.

The first two lines of the output matter for sign-in. The generated authentication code sends a visitor who signs in to after_authentication_url, which returns the page they first asked for or else root_url, and the run printed that root_url does not exist until a root route is drawn.

Checkpoint

Check: The root, redirects and the health check

  1. Question 1 of 2 get "/courses/:id", to: redirect("/batches/%{id}") answered GET /courses/7 with which status?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 301 Moved Permanently

    redirect answers 301 unless a status is given. Browsers and proxies may cache a 301, so it suits a move that will not be undone; pass status: 302 for a temporary one.

  2. Question 2 of 2 The generated after_authentication_url returns the page the visitor first asked for, or else which helper?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: root_url, which exists only once a root route is drawn

    after_authentication_url falls back to root_url. A route helper exists only when its route is declared, so an app with generated authentication needs a root route before the first successful sign-in.

Path helpers for pages, URL helpers for anything that leaves the request

Every named route gives two route helper: A method Rails generates from a named route, such as batch_path or batch_url, that builds the route's path or full URL so controllers, views and mailers never write a URL by hand.. batch_path(7) returns the path, /batches/7, which a link on a Kaksha page can use because the browser already knows the host. batch_url(7) returns the whole URL, scheme and host included. This program calls the helpers of the batches, the shallow enrollments and a singular basket, with no request anywhere:

Path and URL helpers, to_param, direct and resolve Ruby · route_helpers.rb
# Every named route gives two helpers: name_path builds the path alone, name_url the whole URL, host included.
# The helpers below come from Kaksha's batches, its shallow enrollments and a singular basket, drawn into one route
# set; url_helpers is the module that controllers, views and mailers include. Nothing here needs a request.
require "action_controller/railtie"
require "active_model"

routes = ActionDispatch::Routing::RouteSet.new
routes.draw do
  resources :batches do
    resources :enrollments, shallow: true
  end
  resource :basket, only: :show
  direct(:fee_guide) { "https://help.kaksha.example/fees" }
  resolve("Basket") { [:basket] }
end
links = routes.url_helpers

puts "batches_path                  #{links.batches_path}"
puts "batch_path(7)                 #{links.batch_path(7)}"
puts "batch_enrollments_path(7)     #{links.batch_enrollments_path(7)}"
puts "enrollment_path(31)           #{links.enrollment_path(31)}"
begin
  links.batch_url(7)
rescue ArgumentError => error
  puts "batch_url(7), no host set     #{error.class}: #{error.message[/\A[^!]*!/]}"
end
routes.default_url_options = { host: "kaksha.example", protocol: "https" }
puts "batch_url(7), host set        #{links.batch_url(7)}"

# A record is turned into its id segment by to_param, which a model may change.
class Batch
  include ActiveModel::Model
  attr_accessor :id, :name
  def to_param = "#{id}-#{name.parameterize}"
  def persisted? = true
end
june = Batch.new(id: 7, name: "Rails June Weekend")
puts "batch_path(june)              #{links.batch_path(june)}"
puts "an Integer id cast from it     #{ActiveModel::Type::Integer.new.cast('7-rails-june-weekend')}"

# direct names a URL that is not a route; resolve changes where a model's URL points.
class Basket
  include ActiveModel::Model
  def persisted? = true
end
puts "fee_guide_url                 #{links.fee_guide_url}"
puts "polymorphic_path(Basket.new)  #{links.polymorphic_path(Basket.new)}"

Output

batches_path                  /batches
batch_path(7)                 /batches/7
batch_enrollments_path(7)     /batches/7/enrollments
enrollment_path(31)           /enrollments/31
batch_url(7), no host set     ArgumentError: Missing host to link to!
batch_url(7), host set        https://kaksha.example/batches/7
batch_path(june)              /batches/7-rails-june-weekend
an Integer id cast from it     7
fee_guide_url                 https://help.kaksha.example/fees
polymorphic_path(Basket.new)  /basket

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby route_helpers.rb

With no request to take a host from, batch_url(7) raised ArgumentError: Missing host to link to! A mailer is always in that position: the Action Mailer guide notes that it knows nothing of an incoming request, asks for config.action_mailer.default_url_options with the host, and recommends _url helpers in emails because a mail client has no base address to complete a path. Once the host was set, the same call gave https://kaksha.example/batches/7.

Helper Returns Use it in
batch_path(7) /batches/7 links, forms and redirects on pages
batch_url(7) https://kaksha.example/batches/7 emails, feeds and anything read elsewhere

A record passed to a helper becomes its URL segment through to_param, which returns the id unless the model says otherwise. The Batch here returned "7-rails-june-weekend", and an Integer attribute casts that string back to 7, so a lookup by id still finds batch 7. Two more methods add helpers: direct names a URL that no route matches, as fee_guide_url did, and resolve tells polymorphic_path and form_with where a model lives, which sent the singular Basket to /basket.

Key idea

A path is enough inside a page the browser already has; anything read somewhere else needs the whole URL, with a host that comes from configuration.

Checkpoint

Check: Path helpers, URL helpers and the host

  1. Question 1 of 2 Which call belongs in Kaksha's password-reset email?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: edit_password_url(token), with default_url_options giving the host

    A mailer knows nothing of an incoming request. URL helpers with config.action_mailer.default_url_options set the scheme and host that the email's links need.

  2. Question 2 of 2 Batch#to_param returns "7-rails-june-weekend". What does batch_path(batch) give, and what does an Integer attribute cast that segment to?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: /batches/7-rails-june-weekend, and 7

    to_param decides the segment a record gets in a URL. Starting it with the id keeps lookups working, because the Integer type casts "7-rails-june-weekend" to 7.

Practise: read a routes file and predict the route

Every question below comes from Kaksha’s routes file and the tables and runs recorded on this page. Answer one and its explanation opens at once:

Practice that adapts to you

Practise this lesson

14 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 14 How many different URL shapes do the seven actions of resources :batches use?

    Type a number.

    Show the answer: Question 1 of 14

    Answer: 4 URL shapes

    /batches (index and create), /batches/new (new), /batches/:id (show, update and destroy) and /batches/:id/edit (edit). The verb tells the actions on one shape apart.

  2. Question 2 of 14 SessionsController defines new, create and destroy, but resource :session declared seven routes. Which change makes the routes table match the controller?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: resource :session, only: %i[new create destroy]

    :only and :except decide which of the default routes a resource declares. Declaring only the actions a controller defines keeps bin/rails routes an honest list of what the app answers.

  3. Question 3 of 14 How deep does the Routing guide's rule of thumb let resources nest?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: One level

    The rule of thumb is one level. Deeper hierarchies are better served by shallow routes and by resources of their own.

  4. Question 4 of 14 Why does Kaksha create enrollments at POST /batches/:batch_id/enrollments instead of POST /enrollments with a batch_id field in the form?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: The batch comes from the URL the router matched, not from a field a visitor could edit

    Nest the routes that need the parent: listing and creating children. The parent's id then comes from the route, and the controller can load it and check it like any other record.

  5. Question 5 of 14 Which helper lists the fee plans declared inside namespace :admin?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: admin_fee_plans_path

    The prefix column of the table is the helper name without _path: admin_fee_plans for the list, admin_fee_plan for one record.

  6. Question 6 of 14 Kaksha wants /hi/notices and /notices to reach the same NoticesController. Which declaration fits?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: scope "(:locale)", locale: /en|hi/

    Use a scope when only the URL changes. The controller reads the language from params[:locale], filled by the optional segment.

  7. Question 7 of 14 Where does the Routing guide suggest putting the root route, and why?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: At the top of the file, so it is matched first

    The router compares routes in the order they are declared, so the guide puts root first.

  8. Question 8 of 14 A route has constraints: ->(request) { request.subdomain.present? }. Which request reaches it?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: GET http://pune.kaksha.example/dashboard

    A regular expression tests a segment of the path; a lambda or an object with matches? tests the whole request, such as its subdomain.

  9. Question 9 of 14 Inside resources :payments, collection do get :overdue end gives which helper?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: overdue_payments_path

    The recorded table names the row overdue_payments, GET /payments/overdue.

  10. Question 10 of 14 Why is the refund route POST and the overdue route GET?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: A refund changes data and the overdue list only reads it

    Choose the verb by the action: GET for reading, POST, PATCH, PUT or DELETE for changes.

  11. Question 11 of 14 What did GET /up answer in the run?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: 200, with a small HTML page

    The health check answers 200 when the app boots without exceptions and 500 otherwise, which is what load balancers and uptime monitors read.

  12. Question 12 of 14 GET /courses/7?page=2 went through redirect("/batches/%{id}"). What Location did the run record?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: http://kaksha.example/batches/7

    redirect with a string takes the host from the request and fills %{id} from the path. Anything else, such as the query string, needs the block form.

  13. Question 13 of 14 What did batch_url(7) do in the run before default_url_options had a host?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: It raised ArgumentError: Missing host to link to!

    Set config.action_mailer.default_url_options for mailers, or the route set's default_url_options for code that runs outside a request, so URL helpers know the host.

  14. Question 14 of 14 Kaksha declares resource :basket, singular. What makes form_with model: basket and polymorphic_path(basket) build /basket?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: resolve("Basket") { [:basket] } in the routes file

    form_with and polymorphic_path build a model's URL from its class name. For a singular resource, resolve maps the class to the singular route, as the run's polymorphic_path(Basket.new) => /basket showed.

Interview questions

Warm-up: resources :batches declares routes for seven actions. Name them, with the verb and path each answers.

index answers GET /batches and create answers POST /batches. new is GET /batches/new, the form for a new batch. show is GET /batches/:id, edit is GET /batches/:id/edit, update answers both PATCH and PUT on /batches/:id, and destroy answers DELETE /batches/:id. That is four URL shapes for seven actions, because the verb tells them apart, and eight rows in bin/rails routes, because update has two verbs. A singular resource such as resource :session drops index and the :id segment.

Three questions in the module’s bank go further: a route that takes requests meant for another, links in an email that open nothing, and a routes file nested three levels deep.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    In Kaksha, GET /reports/monthly reaches ReportsController#show with params[:id] = "monthly", and the page says no report was found. The routes file declares resources :reports and, below it, get "reports/monthly". Explain what happened, fix it two ways, and say which fix you would keep.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    Kaksha's password-reset email builds its link with edit_password_path(token). Teachers report that the link in the email does not open the reset page, although the same helper works on the website. Explain the cause and the fix, and how every environment gets links to its own host.

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    Kaksha nests payments inside enrollments inside batches, and refunds inside payments, so the list of a payment's refunds needs batch_enrollment_payment_refunds_path(batch, enrollment, payment), and payments also carry accept, decline and reopen routes for refund requests. Propose a cleaner routes file and say how you would change it without breaking links already sent.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Which actions does resources :batches declare, and on how many URL shapes?

    index, new, create, show, edit, update and destroy, on four shapes: /batches, /batches/new, /batches/:id and /batches/:id/edit. The verb tells the actions on one shape apart, and update answers both PATCH and PUT.

  2. How does a singular resource such as resource :session differ from resources?

    It declares no index action and leaves the :id segment out of every path.

  3. What does shallow: true change in a nested resource?

    Routes that list or create children keep the parent in the URL, such as /batches/7/enrollments, while routes for one existing child get their own short URL, such as /enrollments/31. Nest one level at most.

  4. How does namespace :admin differ from scope "(:locale)" in routes.rb?

    A namespace moves the path, the helper names and the controller into a module. A scope like "(:locale)" changes only the path.

  5. A custom route declared below resources :batches never reaches its action. Why, and what fixes it?

    The router takes the first route that fits, and show's /batches/:id fits first. Declare the custom route above resources, or give show a constraint, such as ids made only of digits, so it refuses the request.

  6. How do you add an odd action to a resource, and how do you keep an old URL working?

    Add a member route for one record or a collection route for the list, with the verb that matches what it does. A redirect in routes.rb sends old links on with a 301 unless you give another status.

  7. When do you need batch_url rather than batch_path?

    For anything read outside the current page, such as an email. A path is enough inside a page the browser already has; URL helpers take their host from default_url_options.

Key takeaways

  • resources :batches declares seven actions on four URL shapes, with update reached by PATCH and by PUT, and a singular resource :session has no index and no :id segment.
  • Nest one level at most, let shallow: true give routes for one existing record their own short URLs, and share nested routes between resources with a routing concern.
  • A namespace moves the path, the helper names and the controller into a module, while a scope such as "(:locale)" moves only the path.
  • The router takes the first route that fits, so a custom route below resources loses to show unless it is declared first or a constraint such as { id: /\d+/ } makes show refuse it.
  • Member and collection routes add the odd action with the verb that matches what it does, and redirect keeps old links working with a 301 unless another status is given.
  • Path helpers serve pages, URL helpers serve emails and anything read elsewhere, and their host comes from default_url_options.

Terms in this lesson

resourceful route
A set of routes declared by one resources line (seven actions on four URL shapes) or one singular resource line (no index and no id), each pairing an HTTP verb and a path with a controller action.
route constraint
A test a route adds to its verb and path: a regular expression for one segment, or a lambda or object for the whole request. The route fits only when the test passes, so the router moves on otherwise.
route helper
A method Rails generates from a named route, such as batch_path or batch_url, that builds the route's path or full URL so controllers, views and mailers never write a URL by hand.
routing concern
A named block of routes declared once with concern and added to several resources with concerns:, so routes that more than one resource needs, such as nested reviews, are written in one place.
shallow route
A route of a nested resource that leaves the parent out of the URL once the record has its own id, so /enrollments/31 serves show, edit, update and destroy while the list stays under its batch.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.