Build Your Own Web Framework in Ruby

· 4 min read · Updated March 18, 2026 · beginner
ruby rack web-development frameworks http beginners

Every Ruby web framework you’ve ever used—Rails, Sinatra, Roda, Hanami—shares a common foundation: Rack. It’s the standardized interface between Ruby applications and web servers.

In this tutorial, you’ll build a working web framework from scratch. You’ll understand how routing works, how requests become responses, and why Rack exists in the first place.

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.

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

To run it, create a config.ru file:

require_relative 'app'

run App.new

Then start the server:

rackup -p 3000

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

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

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.

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:

<!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!”

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

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

2. Headers must be lowercase:

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

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

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.

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.

See Also