rubyguides

Build Your Own Web Framework with Rack in Ruby

Every Ruby web framework you’ve ever used shares a common foundation: Rack. Rails, Sinatra, Roda, and Hanami all sit on top of it. This tutorial will show you how to build your own web framework in Ruby from scratch, starting with a bare Rack application and adding routing, templates, and middleware step by step.

Rack is the standardized interface between Ruby applications and web servers. This guide walks through a working web framework from the ground up. You’ll understand how routing works, how requests become responses, and why Rack exists in the first place.

Intro context

Building a framework from scratch is less about replacing Rails and more about understanding the pieces that Rails already gives you. Once you can see how a request becomes a response, the higher-level abstractions in larger frameworks make a lot more sense.

Rack is the right starting point because it keeps the contract tiny. A request comes in as a hash, a response goes out as an array, and everything else is code you write on top. That simplicity makes it easier to experiment without getting lost in framework conventions.

If you are building your own web framework, this is the moment to slow down and look at the boundaries. The app should answer one request at a time, and each layer should have a single job. When you keep that discipline, it becomes much easier to swap in a router, templates, or middleware later without rewriting the whole app.

Why Rack?

Before diving in, let’s understand the problem Rack solves. Web servers speak HTTP, and Ruby speaks Ruby. Rack provides a common language between them:

HTTP Request → Rack → Your App → Rack → HTTP Response

Without Rack, every framework would need custom adapters for every web server (Puma, WEBrick, Unicorn). Rack handles that complexity so you can focus on building your app.

That is the main reason Rack shows up in so many Ruby web projects. It gives you a stable boundary between your app and the server, which means the same app can run in different environments without rewriting the core response logic.

The minimal Rack application

A Rack application needs only one thing: a call method that accepts an environment hash and returns a three-element array.

Create a new file called app.rb:

class App
  def call(env)
    [200, {'content-type' => 'text/plain'}, ['Hello World']]
  end
end

This is a valid web application. The response array contains:

  • 200: HTTP status code
  • {‘content-type’ => ‘text/plain’}: Response headers (lowercase in Rack 3)
  • [‘Hello World’]: Response body as an array of strings

The three-element array is the contract that every Rack application must honor. The status tells the server what happened, the headers describe the response, and the body delivers the actual content. As long as your call method returns this shape, any Rack-compatible server can serve your application without knowing anything else about your code.

To run it, create a config.ru file:

require_relative 'app'

run App.new

The config.ru file is Rack’s configuration entry point. It tells the server which Rack application to run and gives you a place to load middleware, set up the environment, and configure static file serving before the app handles its first request.

Then start the server:

rackup -p 3000

Visit http://localhost:3000 and you’ll see “Hello World”. That’s your first web framework.

The important lesson here is that the framework is already doing useful work, even before you add routes or templates. It knows how to answer a request, how to send a status code, and how to describe the response body in a format Rack understands.

Accessing request data

The env hash contains everything about the incoming request. However, parsing it manually is tedious. Rack provides Rack::Request to help:

require 'rack'

class App
  def call(env)
    req = Rack::Request.new(env)
    
    # Request information
    path = req.path_info          # => "/users/123"
    method = req.request_method   # => "GET"
    params = req.params           # => {"name" => "John"}
    
    # Boolean helpers
    req.get?      # => true for GET
    req.post?     # => true for POST
    
    [200, {'content-type' => 'text/plain'}, ["Path: #{path}"]]
  end
end

Rack::Request wraps the raw environment hash so you can access path, method, and parameters with familiar method calls instead of digging through nested keys. For simple apps, this is all the request parsing you need; larger frameworks add their own routing and parameter layers on top of the same foundation.

Building simple routing

Without a router, every request does the same thing. Let’s add basic routing using a case statement:

require 'rack'

class App
  def call(env)
    req = Rack::Request.new(env)
    path = req.path_info

    case path
    when '/'
      [200, {'content-type' => 'text/html'}, ['<h1>Home Page</h1>']]
    when '/about'
      [200, {'content-type' => 'text/html'}, ['<h1>About Us</h1>']]
    when '/contact'
      [200, {'content-type' => 'text/html'}, ['<h1>Contact</h1>']]
    else
      [404, {'content-type' => 'text/html'}, ['<h1>404 Not Found</h1>']]
    end
  end
end

This is essentially how Sinatra works under the hood: mapping paths to responses.

Once you reach this point, you can see why routing belongs in a framework instead of in every controller action. The router centralizes the path decisions, which keeps the response code focused on the actual content you want to return. A case-statement router works for small apps and is easy to trace, but it does not handle parameterized paths like /users/:id, which is where more sophisticated routers come in.

Adding dynamic content with ERB

Static responses are boring. Let’s use Ruby’s ERB template system to render dynamic HTML:

require 'rack'
require 'erb'

class App
  def call(env)
    req = Rack::Request.new(env)
    
    if req.get? && req.path_info == '/'
      # Get query parameter
      name = req.params['name']
      
      # Render template
      template = File.read('./views/greet.html.erb')
      renderer = ERB.new(template)
      
      # Set local variable for template
      body = renderer.result(binding)
      
      [200, {'content-type' => 'text/html'}, [body]]
    else
      [404, {'content-type' => 'text/html'}, ['<h1>Not Found</h1>']]
    end
  end
end

Create a views directory and add greet.html.erb. This template uses ERB’s <%= %> tags to embed Ruby expressions directly into the HTML, and the conditional block switches between the greeting page and the input form depending on whether a name was submitted.

<!doctype html>
<html>
<body>
  <% if @name %>
    <h1>Hello, <%= @name %>!</h1>
    <p>Welcome to your custom framework.</p>
  <% else %>
    <form method="get" action="/">
      <input type="text" name="name" placeholder="Enter your name">
      <button type="submit">Say Hello</button>
    </form>
  <% end %>
</body>
</html>

Now visiting /?name=Alice displays “Hello, Alice!”

Adding ERB at this stage shows how a tiny framework can grow without changing its basic shape. You still return the same Rack response array, but the body now comes from a template instead of a fixed string. The template engine is completely decoupled from the routing logic, which makes it easy to swap ERB for Haml or Slim later if you decide to change how views are rendered.

Composing middleware

Middleware is code that sits between the request and your application. It can modify requests, responses, or add functionality.

Rack provides Rack::Builder to compose middleware in config.ru:

require_relative 'app'

app = Rack::Builder.new do
  # Add static file serving
  use Rack::Static, root: 'public', urls: ['/css', '/images']
  
  # Set default content type
  use Rack::ContentType, 'text/html'
  
  # Run our app
  run App.new
end

run app

Common middleware includes:

  • Rack::Static; Serve static files
  • Rack::ContentType; Set default Content-Type
  • Rack::Session; Handle sessions
  • Rack::Logger; Log requests

Middleware is where a framework starts to feel reusable. Instead of hard-coding cross-cutting behavior into every route, you can stack that behavior once and let each request pass through it in order. Think of middleware as a pipeline: each layer can inspect or modify the request before it reaches your app, then inspect or modify the response on the way back out.

Important Rack 3 differences

If you’ve used Rack before, know that Rack 3 introduced breaking changes:

1. Response arrays must be mutable:

# WRONG - will fail in Rack 3
[200, {}, ['Hello']].freeze

# CORRECT
def call(env)
  [200, {}, ['Hello']]  # Fresh array each time
end

Rack 3 requires fresh arrays because it may modify the response headers internally, and frozen objects raise an error. Returning a new array literal from call on every invocation is the simplest safe pattern.

2. Headers must be lowercase:

# WRONG
[200, {'Content-Type' => 'text/html'}, [body]]

# CORRECT
[200, {'content-type' => 'text/html'}, [body]]

The lowercase requirement aligns Rack with the HTTP/2 and HTTP/3 specifications, which mandate lowercase header field names. Middleware in Rack 3 expects all header keys to be lowercase and will not normalize them for you.

3. Headers must be a Hash:

# WRONG
[200, [['content-type', 'text/html']], [body]]

# CORRECT
[200, {'content-type' => 'text/html'}, [body]]

What you’ve built

You’ve created a minimal web framework with:

  • A Rack application interface
  • Request parsing with Rack::Request
  • Basic routing with case statements
  • Dynamic templating with ERB
  • Middleware composition

This is the same architecture used by Sinatra and other microframeworks. The difference is they add convenience methods and more sophisticated routing.

That gives you a useful checklist for the next iteration. If the app starts to feel repetitive, move the repeated response bits into helpers. If the route tree starts to sprawl, extract a router object. If HTML generation starts to crowd the request code, move the template loading into its own method. Each of those steps keeps the example small while showing how a real app grows.

It also gives you a practical way to compare frameworks. When you try Rails, Sinatra, or Roda later, you will already know which problems they solve for you and which pieces they expect you to organize yourself. That makes the next framework easier to evaluate because you can focus on the tradeoffs instead of the syntax.

If you want to build your own web framework beyond this first pass, treat each feature as a separate layer. Add request helpers first, then extract response formatting, then introduce a router or middleware only when the code begins to repeat. That order keeps the framework understandable and makes each new abstraction easier to justify.

Where to go next

To extend your framework, consider adding:

  • Parameterized routes like /users/:id
  • POST request handling with form parsing
  • Error handling and custom error pages
  • A DSL for cleaner route definitions

The sky’s the limit once you understand the foundation.

At this point, the biggest payoff is conceptual, not just practical. You know where routing lives, where templating fits, and where middleware belongs, which means the next framework you use will feel much less mysterious.

See Also