This is the third article in the series on building a web application in Ruby without Rails.
In this post, we'll learn how routing works in its essence and build our own router from scratch, without using any third-party gems. We'll use some metaprogramming, but I promise it's nothing complicated.
Our router will look somewhat similar to Rails. It will accept a URI and a block, providing a very simple and expressive method to define routes and behavior without complicated routing configuration.
Router.draw do
get('/') { 'Hello world' }
end
Note: If you haven't read them already, check out the first and second articles:


In future articles in the series, we'll implement our own controllers and models, process forms and query data, connect to the database, write our own authentication, and much more. All in plain Ruby, too! If that sounds interesting, consider subscribing to the blog.
Finally, if you don't want to read the full article (>2000 words) and just want to see the final code, check out the GitHub repository and switch to the rails-router
branch.
Alright, let's start by understanding what a router is and what it does, in the context of a web application.
What is a Router?
A router is that part of the web application that determines where the incoming request should go. It figures it out by examining the request URL and then invoking a pre-defined function (or a handler) for that path. Internally, it stores the mapping between the URL patterns and the corresponding handlers.
In Ruby on Rails, the router sends the incoming request to an action method on a controller. In the next post, we'll build our own controllers and modify our router (that we'll build today) to dispatch the request to the controller#action
code.

To learn more about the Rails Router in-depth, please read the following post:

In fact, it would be better if you first read the above article and then come back to this one. That will give you a much better context to understand this post. Don't sweat it, though! I assume no prior knowledge of routing to understand this post.
Current Setup
If you've read the first article in the series, you should have the following script, which is a barebone, simple-yet-complete web application. To keep things simple, I've removed the additional code to serve dynamic views that we added in the previous article.
class App
def call(env)
headers = { 'Content-Type' => 'text/html' }
response = ['<h1>Hello World!</h1>']
[200, headers, response]
end
end
Right now, our application does only one thing. Whenever a request arrives, it returns the response Hello World!
to the browser. It sends the same response, regardless of the request URL.
➜ curl localhost:9292
<h1>Hello World</h1>
➜ curl localhost:9292/posts
<h1>Hello World</h1>
➜ curl localhost:9292/posts/new
<h1>Hello World</h1>
Having a web application that returns the same response for every request isn't very exciting... or useful! Let's make it smart by returning a different response based on the incoming HTTP request's path.
To keep things simple, I'll make three assumptions:
- Our application only supports HTTP GET requests,
- It only needs to handle the following three routes. For all other URLs, it returns
no route found
message.
URL Pattern | Action |
---|---|
/ | shows the home page |
/articles | shows all articles |
/articles/1 | shows a single article |
/random | "no route found" |
Let's get to it.
The Router Class
Let's create a new router.rb
file in the current directory with the following code.
# weby/router.rb
class Router
def initialize
@routes = {}
end
def get(path, &blk)
@routes[path] = blk
end
def build_response(path)
handler = @routes[path] || -> { "no route found for #{path}" }
handler.call
end
end
The Router class maintains a @routes
Hash as the internal data structure to store the URL patterns along with their corresponding handlers.
If you're not that familiar with blocks and lambdas in Ruby, this code might not make much sense. I recommend you read the following post for a deeper understanding of blocks, procs, and lambdas.

The get
method takes a URL path and a block as arguments. The block represents the handler code that should be executed when a request matching that path arrives. It then stores the path along with the handler in the @routes
hash.
def get(path, &blk)
@routes[path] = blk
end
Finally, the build_response
method takes the path of the current request. It finds the corresponding handler from the @routes
mapping. If the path is not stored in the mapping, we set the handler to a default lambda that returns the message no route found
.
def build_response(path)
handler = @routes[path] || -> { "no route found for #{path}" }
handler.call
end
Once we find the handler block for the current path, we call
it and return the generated output back to the application.
Using the Router
Let's put the Router
class to good use.
First, we define the routes that our application needs, and then we use the build_response
method on the router to generate the HTTP response.
# weby/app.rb
require_relative './router'
class App
attr_reader :router
def initialize
@router = Router.new
router.get('/') { "Akshay's Blog" }
router.get('/articles') { 'All Articles' }
router.get('/articles/1') { "First Article" }
end
def call(env)
headers = { 'Content-Type' => 'text/html' }
response_html = router.build_response(env['REQUEST_PATH'])
[200, headers, [response_html]]
end
end
Note that we're setting up the router and initializing the routes in the constructor, and not in the call
method. This is important. The constructor is called at the beginning, and never again. In contrast, the call
method is invoked every time a new request comes in. We want to initialize the router only once, at the beginning.
And we're done. Restart the application, and be prepared to be amazed.

Other routes work as expected, too.
➜ curl localhost:9292
Akshay's Blog
➜ curl localhost:9292/articles
All Articles
➜ curl localhost:9292/articles/1
First Article
➜ curl localhost:9292/articles/x
no route found for /articles/x
➜ curl localhost:9292/random-url
no route found for /random-url
We have a functioning router implementation. Woohoo!
But It Doesn't Look Like Rails!
At the start of the post, I promised the following router API that looked similar to what we have in Rails.

The current implementation doesn't look like that at all!
I know, I know. I decided to build the simplest router first as jumping directly in the above implementation would've meant compressing a lot of things together. But now that we have a simple and functioning router, we can refactor and improve it to make it more Rails-like in three simple steps:
Step 1: Refactor the Router
Let's make a few small changes to the router, so it looks like this.
# weby/router.rb
require 'singleton'
class Router
include Singleton # 1
attr_reader :routes
class << self
def draw(&blk) # 2
Router.instance.instance_exec(&blk) # 3
end
end
def initialize
@routes = {}
end
def get(path, &blk)
@routes[path] = blk
end
def build_response(path)
handler = @routes[path] || ->{ "no route found for #{path}" }
handler.call
end
end
Three important things to note:
- I've made the
Router
class a Singleton so we always have a single instance to work with. Refer theSingleton
documentation to learn more about how it works. In short, the Singleton pattern ensures that a class has only one globally accessible instance. - Added a
draw
method on theRouter
class so we could call it asRouter.draw
. This is a syntactic sugar to mimic Rails. - The
draw
method accepts a block and executes that block in the context of the instance of theRouter
class. This is exactly what the Rails Router does. Refer to theinstance_exec
documentation to learn how it works.
Now, let's put this Router
class to good use by creating the routes in a separate file.
Step 2: Create the Routes
In the spirit of Rails, let's create a config/routes.rb
file so we have a Rails-like structure. This file contains our application routes.
# config/routes.rb
require_relative '../router'
Router.draw do
get('/') { "Akshay's Blog" }
get('/articles') { 'All Articles' }
get('/articles/1') { "First Article" }
end
Note that we're calling the draw
method on the Router
and passing a block. Ruby will execute this block (code within do..end
above) in the context of the instance of the Router
. Hence, the self
object in that block will be the Router
.
The above code is similar to:
router = Router.new
router.get('/') { "Akshay's Blog" }
# and so on...
Don't you just love metaprogramming in Ruby?
If you're curious to learn more about metaprogramming, check out the following article.

There's only one thing remaining. Use those routes!
Step 3: Update the Application to use New Routes
Since we have defined the routes elsewhere, we don't need them in the application constructor. Let's remove them.
Here's the new app.rb
file. Much cleaner, right?
# weby/app.rb
require_relative './config/routes'
class App
def call(env)
headers = { 'Content-Type' => 'text/html' }
response_html = router.build_response(env['REQUEST_PATH'])
[200, headers, [response_html]]
end
private
def router
Router.instance
end
end
Note that we're using the Router.instance
method added by the Singleton
module to get the Router
's instance.
That's it. Restart the application and give it a try. Everything should work as expected.
There's one small improvement we can do to make our Router more flexible and powerful.
Let's Pass the HTTP Environment to Routes
Right now, our routes cannot access the incoming request, i.e. the env
hash. It would be really nice if they could use env
to add more dynamic behavior. For example, you could access the cookies and session data in your handlers via the HTTP request.
Let's fix it.
env
hash in the app.rb
. How would you pass it to the handlers?All we need to do is pass env
to the Router
and then further pass it to the handlers when we call it. Here's the changelog.
# app.rb
response_html = router.build_response(env)
# router.rb
def build_response(env)
path = env['REQUEST_PATH']
handler = @routes[path] || ->(env) { "no route found for #{path}" }
handler.call(env) # pass the env hash to route handler
end
# config/routes.rb
get('/articles/1') do |env|
puts "Path: #{env['REQUEST_PATH']}"
"First Article"
end
If you're curious how the above code works, especially how the handlers remember the env
hash, read this: capture variables outside scope.
Now all our route handlers have access to the incoming HTTP request. Later, we'll wrap this env
hash into a dedicated Request
class for exposing a better API, just like the ActionDispatch::Request
class in Rails.
That's a wrap. In future articles in this series, we'll explore the following topics.
- Introduce controllers, models, and views, just like Rails!
- Improve the project structure and organization
- Add unit tests
- Handle errors and logging
- Process form inputs along with query strings into a
params
object - Connect to the database to store and fetch data
- Add middleware to handle specific tasks like authentication
- and much more...
If those sound interesting to you, consider subscribing to the blog.
Trust me, it's going to be a lot of fun, so stay tuned!!
I hope you liked this article and you learned something new.
As always, if you have any questions or feedback, didn't understand something, or found a mistake, please leave a comment below or send me an email. I look forward to hearing from you.
If you'd like to receive future articles directly in your email, please subscribe to my blog. If you're already a subscriber, thank you.