rubyguides

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 requests
  • r.post; matches POST requests
  • r.put / r.patch; match PUT and PATCH requests
  • r.delete; matches DELETE requests

The key methods for building your tree:

MethodPurpose
r.onAdds a segment constraint, continues matching
r.isMatches the remaining path exactly
r.rootMatches the root path /
r.paramsAccesses 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.

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