Building Web Apps with Roda: A Routing Tree Framework
Building web applications with Roda means thinking in routing trees instead of flat route lists. Roda is a Ruby web framework that takes a different approach to routing: instead of matching routes one by one, it organizes routes hierarchically in a tree structure. This design makes your application faster and your routing code more organized.
intro context
Roda is a good example of what a framework looks like when it stays opinionated about routing but flexible about the rest. You still write Ruby, but the framework pushes you to think in branches instead of in a long flat list of route handlers.
That matters because the code starts to describe the app’s shape. A nested route tells you which actions belong together, and that makes the file easier to scan when the app grows. The examples below lean on that idea so the structure stays clear even when the routing tree gets deeper.
Why Roda?
If you have used Rails or Sinatra, you might expect a flat list of routes. Roda changes this by letting you nest routes based on their path segments. Consider a typical blog application:
class BlogApp < Roda
route do |r|
r.root do
@articles = Article.latest(10)
view "home"
end
r.on "articles" do
r.on Integer do |article_id|
@article = Article.find(article_id)
r.is do
view "article/show"
end
r.on "edit" do
view "article/edit"
end
end
r.is do
view "articles/index"
end
end
end
end
This routing tree processes only the segments relevant to each branch. When a request comes to /articles/42, Roda skips the root and article index branches entirely, going straight to the integer segment match.
That tree structure is especially useful in APIs and admin interfaces, where related actions usually live under the same prefix. It keeps each branch close to the code it affects, which makes future changes easier to place and easier to review.
Installation and setup
Install Roda with bundler. The framework has no other mandatory dependencies, so adding it to your Gemfile is the only required setup step:
gem "roda"
Create a minimal application. With the gem installed, you can write a Roda app in a single file. The core idea is simple: subclass Roda, define a route block, and call run! to start the server. Here is the smallest working example:
# app.rb
require "roda"
class App < Roda
route do |r|
r.get "hello" do
"Hello, World!"
end
end
end
App.run!
Run it with ruby app.rb and visit http://localhost:9292/hello.
The setup is intentionally small, but it already shows Roda’s philosophy. The app is plain Ruby, the route tree is explicit, and plugins stay optional until you need them. That combination makes Roda a comfortable middle ground between a microframework and a full-stack framework.
The routing tree in detail
The routing block receives a request object r that you use to match path segments. Each method on r corresponds to an HTTP verb:
r.get; matches GET requestsr.post; matches POST requestsr.put/r.patch; match PUT and PATCH requestsr.delete; matches DELETE requests
The key methods for building your tree:
| Method | Purpose |
|---|---|
r.on | Adds a segment constraint, continues matching |
r.is | Matches the remaining path exactly |
r.root | Matches the root path / |
r.params | Accesses query and body parameters |
Those helpers are enough to model most applications without forcing you into a complicated abstraction. The route block stays readable because the tree itself tells the story.
Understanding segment matching
Each r.on call consumes exactly one path segment:
r.on "users" do # matches /users
r.on Integer do |id| # matches /users/123
# id is now 123
end
end
You can nest these branches as deeply as the domain requires, but each branch should still do one thing. When a branch starts handling too many unrelated paths, it is usually a sign that you should extract a helper method or split the branch into smaller pieces.
Roda segment matchers accept more than just strings. You can match by type, capture the matched value, or use a regular expression for fine-grained control. This flexibility means you rarely need to write manual path parsing inside a route block:
# Match a specific string
r.on "admin" do; end
# Match a numeric segment
r.on Integer do |n|; end
# Match any segment (captures it)
r.on String do |segment|; end
# Match with a regex
r.on /\A(\w+)\z/ do |match|; end
Each matcher type covers a different routing need. Strings match static segments, integers match numeric IDs and bind the value, strings with a block variable capture any single segment for dynamic slugs, and regular expressions give you full control for unusual formats.
Request and response
The request object provides access to everything about the incoming HTTP request. These helpers are all available inside any route block:
r.get? # true for GET requests
r.post? # true for POST requests
r.params # Hash of query and body parameters
r.headers # Hash of HTTP headers
r.body # Request body as IO
r.ip # Client IP address
r.url # Full request URL
r.path # Request path string
The response object exposes status, headers, and body, giving you full control over the outgoing HTTP message. Each field is a simple assignment or hash key, so you can shape the response incrementally as the route logic progresses:
r.response.status = 201
r.response["X-Custom-Header"] = "value"
r.response["Content-Type"] = "application/json"
This direct response API keeps request handling and response shaping close together. You do not need a separate controller layer just to set a status code or add a header. For small apps, that simplicity is often enough. Setting headers one at a time also makes it easy to add conditional response values without restructuring the entire output path.
For convenience, you can return values directly from the route block:
r.get "data" do
{ json: "data" }.to_json
end
That pattern works well for API endpoints that mostly serialize Ruby objects. Once you know the request object and the response object are both available in the route block, you can build a clean JSON response without much ceremony. Returning a value directly is shorthand for setting r.response.body, so you can still mix both styles in the same application. Once you know the request object and the response object are both available in the route block, you can build a clean JSON response without much ceremony.
Plugins: extending Roda
Roda stays small by moving most features into optional plugins. Include them with plugin. Each plugin adds exactly one capability, so your application only carries the features it explicitly requests. Returning a value directly from a route block is shorthand for setting the response body, so you can mix both styles in the same application:
class App < Roda
plugin :render
plugin :json
plugin :sessions,
key: "_app_session",
secret: ENV["SESSION_SECRET"]
end
Essential plugins for most applications:
- render; Template rendering with support for ERB, Haml, Slim
- json; JSON request parsing and response helpers
- sessions; Cookie-based session management
- cookies; Cookie reading and writing
- halt; Stop request processing early
- flash; Flash message support
Rendering Views
With the render plugin:
class App < Roda
plugin :render
route do |r|
r.get "about" do
view "about"
end
end
end
This looks for views/about.erb by default. Customize the directory and engine if your templates live elsewhere or use a different format like Haml or Slim. The render plugin supports multiple engines and lets you keep views organized by convention:
plugin :render, views: "app/views", engine: "erb"
Plugins are where Roda grows without turning into a kitchen sink. You only add the pieces you need, and each plugin stays isolated enough that you can reason about it on its own.
Common Gotchas
Path matching does not skip middleware
Unlike Sinatra, Roda routes run after middleware. If you need path-specific middleware, handle it in the routing tree. Middleware that applies to the whole app, like session handling, should be registered with use at the class level. Path-specific logic, like authentication checks for /api, belongs inside the route block where it only runs for matching requests:
class App < Roda
use Rack::Session::Cookie, secret: "secret"
route do |r|
r.on "api" do
# Path-specific logic inside the block
# For authentication, use Rack middleware at the top level
# or check r.headers["Authorization"] here
# API routes here
end
# Regular routes here
end
end
Blocks execute on every request
The route block runs for every request. Avoid expensive computations at the top level:
# Bad — database query on every request
route do |r|
@all_users = User.all
# ...
end
# Good — query only when needed
r.on "users" do
@users = User.all # only for /users routes
end
This is the same performance advice you would give in any Ruby application, but the route tree makes it easier to apply. Keep the expensive work close to the branch that needs it, and the rest of the tree stays cheap to evaluate.
Matching order matters
Roda processes the routing tree top-down. More specific routes should come before general ones:
# Wrong — "users" matches Integer branch first in some cases
r.on Integer do |id|
# matches /123
end
r.on "users" do
# may never match if integer catches it
end
# Correct — specific paths first
r.on "users" do
r.on Integer do |id|; end
end
r.on Integer do |id|; end
That ordering rule is easy to forget when you are new to Roda. When in doubt, place the branch that should win for the more specific request path first.
A complete example
Here is a simple REST API with JSON responses. This example combines the routing tree, the JSON plugin, and the halt plugin into a single working application that handles the full CRUD cycle for a list of articles. The tree evaluates from top to bottom, and the first matching branch wins, so the most constrained path always appears above the more general one:
require "roda"
class ApiApp < Roda
plugin :json
plugin :halt
@@articles = [
{ id: 1, title: "First Post" },
{ id: 2, title: "Second Post" }
]
route do |r|
r.on "articles" do
r.is do
# GET /articles — list all
r.get { @@articles }
# POST /articles — create
r.post do
id = @@articles.last[:id] + 1
article = { id: id, title: r.params["title"] }
@@articles << article
r.response.status = 201
article
end
end
r.on Integer do |id|
# GET/PUT/DELETE /articles/:id
r.get { @@articles.find { |a| a[:id] == id } }
r.put do
article = @@articles.find { |a| a[:id] == id }
article[:title] = r.params["title"] if article
article
end
r.delete do
@@articles.reject! { |a| a[:id] == id }
""
end
end
end
r.on "health" do
r.get { { status: "ok" } }
end
end
end
ApiApp.run!
This final example ties the pieces together. You have a tree for path selection, plugins for JSON and halting, and standard Ruby code for the data store. If you wanted to grow this further, you could add persistence, validation, or authentication without changing the routing style.
Conclusion
Roda’s routing tree approach offers a refreshing alternative to traditional flat-route frameworks. By organizing routes hierarchically, you get faster request handling and cleaner code structure. Start with the basics; r.on for branches, r.is for leaf nodes; and add plugins only when you need them. The framework stays out of your way until you explicitly ask it to help.
That makes Roda especially appealing when you want control without a lot of ceremony. It does not hide the request lifecycle, but it does give you enough structure to keep the app readable as it grows.
forward-link
If you want to see another routing style that still builds on Rack, the next article on building-a-web-framework walks through a simpler framework from the bottom up.
See Also
- Rails routing; Understanding RESTful routes in Rails
- Hanami actions and views; Another Ruby framework with action-based routing
- Rails API mode; Building JSON APIs with Rails