Ruby on Rails Zero to Advanced #5: Routing, Controllers & Views
In part 4 we built a single static page. Real apps have many pages that respond to the URL and to user input. In this part we follow a request through the three layers that handle it before any database is involved: the router, the controller and the view. To stay focused, our articles live in a plain Ruby array for now; in part 6 we replace it with a real database table.
Routes and resources
All routes live in config/routes.rb. You can write each one by hand, but Rails' resources generates the seven standard RESTful routes in one line:
# config/routes.rb
Rails.application.routes.draw do
root "articles#index"
resources :articles
end
bin/rails routes -c articles
Output:
Prefix Verb URI Pattern Controller#Action
root GET / articles#index
articles GET /articles(.:format) articles#index
POST /articles(.:format) articles#create
new_article GET /articles/new(.:format) articles#new
edit_article GET /articles/:id/edit(.:format) articles#edit
article GET /articles/:id(.:format) articles#show
PATCH /articles/:id(.:format) articles#update
PUT /articles/:id(.:format) articles#update
DELETE /articles/:id(.:format) articles#destroy
Each verb + path pair maps to one action. The :id segment becomes params[:id]. Since we only read articles in this part, restrict the routes and add a custom collection route:
Rails.application.routes.draw do
root "articles#index"
resources :articles, only: [:index, :show] do
get :latest, on: :collection
end
end
Route helpers turn names into URLs. Try them in bin/rails console through the app object:
app.articles_path
app.article_path(2)
app.articles_path(author: "Siti")
app.latest_articles_path
Output:
=> "/articles"
=> "/articles/2"
=> "/articles?author=Siti"
=> "/articles/latest"
Always use helpers like article_path(article) in your code instead of hard-coding "/articles/" + id; if a URL changes later, you only update the routes file.
Controllers and params
A controller is a class whose public methods are actions. Everything the user sends, whether path segments (:id), query strings (?author=Budi) or form fields, arrives in the params hash.
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
ARTICLES = [
{ id: 1, title: "Getting started with Ruby", author: "Budi" },
{ id: 2, title: "Why Rails is productive", author: "Siti" },
{ id: 3, title: "Ruby blocks explained", author: "Budi" }
].freeze
def index
@articles = ARTICLES
if params[:author].present?
@articles = @articles.select { |a| a[:author] == params[:author] }
end
end
def show
@article = ARTICLES.find { |a| a[:id] == params[:id].to_i }
render plain: "Article not found", status: :not_found unless @article
end
def latest
redirect_to article_path(ARTICLES.last[:id])
end
end
Visit http://localhost:3000/articles/2?ref=home and look at the server log:
Output:
Started GET "/articles/2?ref=home" for 127.0.0.1 at 2026-10-13 10:15:42 +0700
Processing by ArticlesController#show as HTML
Parameters: {"ref" => "home", "id" => "2"}
Rendering layout layouts/application.html.erb
Rendering articles/show.html.erb within layouts/application
Rendered articles/show.html.erb within layouts/application (Duration: 0.8ms | GC: 0.0ms)
Rendered layout layouts/application.html.erb (Duration: 5.1ms | GC: 0.0ms)
Completed 200 OK in 12ms (Views: 6.3ms | GC: 0.0ms)
Two important details: every param is a string ("2", not 2), which is why we call .to_i; and when an action does not call render or redirect_to, Rails renders the template with the same name (articles/show.html.erb) automatically.
render vs redirect_to
render builds the response now, inside the current request. redirect_to tells the browser to make a new request to another URL. Check the latest action with curl:
curl -I http://localhost:3000/articles/latest
curl -i http://localhost:3000/articles/99
Output:
HTTP/1.1 302 Found
location: http://localhost:3000/articles/3
content-type: text/html; charset=utf-8
...
HTTP/1.1 404 Not Found
content-type: text/plain; charset=utf-8
...
Article not found
Use status symbols such as :not_found, :unprocessable_entity or :created instead of raw numbers; they read better and Rails maps them to the correct codes.
Views, ERB and partials
Views are HTML with embedded Ruby. Instance variables from the action (@articles) are available inside them. Repeated fragments go into partials, files whose names start with an underscore.
<!-- app/views/articles/index.html.erb -->
<h1>Articles</h1>
<%= render partial: "article", collection: @articles %>
<p><%= pluralize(@articles.size, "article") %></p>
<!-- app/views/articles/_article.html.erb -->
<article>
<h2><%= link_to article[:title], article_path(article[:id]) %></h2>
<p>by <%= article[:author] %></p>
</article>
Open /articles?author=Budi:
Output:
<h1>Articles</h1>
<article>
<h2><a href="/articles/1">Getting started with Ruby</a></h2>
<p>by Budi</p>
</article>
<article>
<h2><a href="/articles/3">Ruby blocks explained</a></h2>
<p>by Budi</p>
</article>
<p>2 articles</p>
With collection:, Rails renders the partial once per element and exposes each one as a local variable named after the partial (article). Helpers like link_to and pluralize keep views short.
Layouts and automatic escaping
Every view is wrapped by app/views/layouts/application.html.erb. The view's content is inserted where the layout calls yield, and a view can send extra content to the layout with content_for:
<!-- app/views/layouts/application.html.erb (simplified) -->
<!DOCTYPE html>
<html>
<head>
<title><%= content_for(:title) || "Blog" %></title>
</head>
<body>
<nav><%= link_to "Home", root_path %></nav>
<%= yield %>
</body>
</html>
<!-- app/views/articles/show.html.erb -->
<% content_for :title, @article[:title] %>
<h1><%= @article[:title] %></h1>
<p>Written by <%= @article[:author] %></p>
<p><%= "<script>alert('hi')</script>" %></p>
<%= link_to "Back to articles", articles_path %>
Output for /articles/2:
<!DOCTYPE html>
<html>
<head>
<title>Why Rails is productive</title>
</head>
<body>
<nav><a href="/">Home</a></nav>
<h1>Why Rails is productive</h1>
<p>Written by Siti</p>
<p><script>alert('hi')</script></p>
<a href="/articles">Back to articles</a>
</body>
</html>
Notice the script tag was escaped. <%= %> escapes HTML by default, protecting you from cross-site scripting (XSS). Only bypass it with raw or html_safe for content you fully control.
Common mistakes
- Comparing params to integers.
params[:id] == 1is always false because params are strings. Convert with.to_i(Active Record'sfinddoes this for you). - Rendering twice. Calling
renderand thenredirect_toin one action raisesAbstractController::DoubleRenderError. Usereturnorand returnafter an early render. - Local variables instead of instance variables.
articles = ...in the action is invisible to the view. Use@articles. - Route order. Routes match top to bottom. A custom
get "articles/latest"placed afterresources :articlesis captured byarticles/:id. Useon: :collectionas shown above. - Logic in views. Filtering and calculations belong in the controller or model, not in ERB.
Exercise
Add a search box: when the URL has ?q=ruby, the index should only list articles whose title contains the keyword (case-insensitive). Hint:
if params[:q].present?
keyword = params[:q].downcase
@articles = @articles.select { |a| a[:title].downcase.include?(keyword) }
end
Output for /articles?q=ruby:
Getting started with Ruby
Ruby blocks explained
2 articles
Bonus: add a form with <%= form_with url: articles_path, method: :get do |f| %> and a f.search_field :q so users can type the keyword.
Summary
resources generates RESTful routes and URL helpers, controllers read user input from params and decide whether to render or redirect_to, and views use ERB, partials and layouts to produce safe, escaped HTML. In part 6 we swap the hard-coded array for a real database using Active Record migrations and models, and practise CRUD in the Rails console.
Related articles
PostgreSQL Zero to Advanced #5: Aggregation, GROUP BY & HAVING
Turn raw orders into business numbers: total revenue, average order value, sales per month and top customers using COUNT, SUM, AVG, GROUP BY, HAVING and FILTER.
PHP Zero to Advanced #5: Arrays & Array Functions
Master PHP arrays: indexed and associative arrays, nested data, destructuring, and the essential functions array_map, array_filter, array_reduce, usort, and friends.
Python Zero to Advanced #5: Lists, Tuples, Dictionaries, Sets & Comprehensions
Master Python's built-in collections: lists, tuples, dictionaries, and sets, when to use each, slicing, unpacking, and writing concise list, dict, and set comprehensions.