Build Your Own Web Framework in Ruby
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 filesRack::ContentType— Set default Content-TypeRack::Session— Handle sessionsRack::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
- Rack Middleware from Scratch — Learn how to build custom middleware
- Roda Framework — See how a minimal framework approaches routing
- Sinatra Getting Started — Compare your implementation to Sinatra