Build Your Own URL Shortener with Rack

David Morales David Morales
/
A URL shortener, where a gray arrow with a long URL passes through a large red gear with rubies and emerges as a shorter blue arrow with a short URL

In this tutorial, we’re going to build a minimalist URL shortener (like bit.ly) from scratch, using only Rack and Ruby’s standard library. We won’t use frameworks like Rails or Sinatra. Just pure Rack.

If you’re new to Rack or want to review the fundamentals before starting, I recommend reading this complete beginner’s guide to Rack.

Besides building the core functionality, we’ll take a deep dive into the anatomy of an HTTP request within Rack.

By the end, you will understand the following concepts:

Setting Up the Environment

Before we begin, let’s prepare our environment. Create a new folder for the project and navigate into it:

Terminal window
mkdir url-shortener
cd url-shortener

Now, initialize Bundler to manage our dependencies:

Terminal window
bundle init

This will create a Gemfile. Now add the gems we’ll need:

Terminal window
bundle add rack puma

Finally, create the directory structure we’ll use:

Terminal window
mkdir -p app/views app/middleware
touch config.ru app/shortener.rb app/store.rb

Creating the Storage System

Before we can shorten URLs, we need a place to store them. We’ll create a Store class that will handle persistence using a simple JSON file.

Create the Store Class

First, let’s define the class and its initializer.

app/store.rb
require "json"
require "securerandom"
module URLShortener
class Store
DB_FILE = "db.json"
def initialize
# If the file doesn't exist, create it with an empty hash
File.write(DB_FILE, "{}") unless File.exist?(DB_FILE)
# Load the data into memory
@data = JSON.parse(File.read(DB_FILE))
end
end
end
What is a magic string?

A “magic string” is a text value (String) used directly in the code without any explanation of its purpose.

Think about it this way:

  • Bad (with a magic string): File.write("db.json", "{}")
  • Good (without a magic string): File.write(DB_FILE, "{}")

Why are they a problem?

  1. They make maintenance harder: If you wanted to change the filename from "db.json" to "database.json", you would have to find and replace that string in every place it appears. It’s easy to miss one and cause a bug.
  2. They are prone to typos: It’s very easy to make a typo ("db.json" vs. "db,json") in one spot and spend hours hunting down the bug.
  3. They reduce readability: A constant name like DB_FILE immediately explains what that value is. The string "db.json" alone doesn’t provide as much context.

Saving a New URL

Now for the most important method: save. Its job is to take an original URL, generate a unique identifier (the “slug”), save the information, and return the slug.

app/store.rb
3 collapsed lines
def initialize
# ...
end
def save(original_url)
# Generate a unique 4-character code
slug = generate_slug
# Save the information with metadata
@data[slug] = {
"url" => original_url,
"created_at" => Time.now.to_i,
"clicks" => 0
}
# Write the changes to the file
persist!
# Return the slug so the app can use it
slug
end

Retrieving and Updating Data

We also need methods to read the data we’ve saved.

app/store.rb
3 collapsed lines
def save(original_url)
# ...
end
def find(slug)
@data[slug]
end
def increment_clicks(slug)
return unless @data[slug] # Do nothing if the slug doesn't exist
@data[slug]["clicks"] += 1
persist! # Save the change to the file
end
def all
@data
end

Helper Methods (Private)

Finally, let’s implement the private methods that save and increment_clicks use.

app/store.rb
3 collapsed lines
def all
# ...
end
private
def generate_slug
# Loop until we find a slug that doesn't exist
loop do
# SecureRandom.alphanumeric generates cryptographically secure random characters
slug = SecureRandom.alphanumeric(4).downcase
return slug unless @data.key?(slug)
end
end
def persist!
# Save with pretty_generate to make the file human-readable if opened
File.write(DB_FILE, JSON.pretty_generate(@data))
end

Creating the Router

Now that we can save data, let’s make our application respond differently depending on the visited URL. We’re going to implement a manual routing system.

Structure and Routing

app/shortener.rb
require "rack"
require "erb"
require_relative "store"
module URLShortener
class ShortenerApp
def initialize(store: Store.new)
@store = store
end
def call(env)
# Rack::Request wraps the env hash with convenient methods
request = Rack::Request.new(env)
# Routing based on HTTP method and path
route(request)
end
private
def route(request)
# Use pattern matching to decide what to do
case [request.request_method, request.path_info]
when ["GET", "/"]
render_home
else
not_found
end
end
end
end

The route method combines the HTTP method and the path into an array [request.request_method, request.path_info] and uses case/when to decide what to do. This makes the routing very readable.

Creating Actions and Views

Now let’s implement the methods that are called by our router (the “actions”) and the ERB templates that generate the HTML (the “views”).

The Home Page

This is the main action of our application. Its purpose is to display the form for shortening a new URL and to list the links that have already been created.

app/shortener.rb
3 collapsed lines
def route(request)
# ...
end
def render(view_name, locals = {})
layout = File.read("#{__dir__}/views/layout.erb")
view_code = File.read("#{__dir__}/views/#{view_name}.erb")
view_content = ERB.new(view_code).result_with_hash(locals)
final_body = ERB.new(layout).result_with_hash(content: view_content)
ok_html(final_body)
end
def render_home
render("home", links: @store.all)
end
def ok_html(body)
[200, { "Content-Type" => "text/html", "Content-Length" => body.bytesize.to_s }, [body]]
end
def not_found
[404, { "Content-Type" => "text/plain" }, ["Not Found"]]
end
def bad_request(message)
[400, { "Content-Type" => "text/plain" }, [message]]
end
app/views/layout.erb
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Rack URL Shortener</title>
<style>
:root { --bg: #ffffff; --fg: #111827; --accent: #4f46e5; }
@media (prefers-color-scheme: dark) {
:root { --bg:#0f172a; --fg:#f1f5f9; --accent:#818cf8; }
}
body { background:var(--bg); color:var(--fg); font-family:monospace; max-width:680px; margin:3rem auto; }
input[type=url] { width:100%; padding:0.7rem; border:1px solid #94a3b8; border-radius:6px; }
button { padding:0.6rem 1.2rem; margin-top:0.6rem; border:none; border-radius:6px; background:var(--accent); color:#fff; cursor:pointer; }
table { width:100%; margin-top:1.8rem; border-collapse:collapse; }
th,td { border-bottom:1px solid #e2e8f0; padding:0.5rem; text-align:left; }
a { color:var(--accent); text-decoration:none; }
code { background:#f1f5f9; color: #334155; padding:0.25rem; border-radius:4px; }
</style>
</head>
<body>
<h1>đź”— Rack URL Shortener</h1>
<%= content %>
</body>
</html>
app/views/home.erb
<h2>Shorten a URL</h2>
<p>Soon you will be able to shorten URLs here.</p>

Now, edit config.ru, which is the file Rack looks for to know how to run the application:

config.ru
require_relative 'app/shortener'
run URLShortener::ShortenerApp.new

Start the server with bundle exec puma and try visiting /. Also, try a non-existent route like /foo to see the 404 response.

The URL Shortening Form

It’s time to add the main functionality. We need a form where the user can enter a URL and an endpoint to process that form.

First, let’s improve our home page with a real form:

app/views/home.erb
<form action="/shorten" method="POST">
<label for="url">Enter a URL to shorten:</label><br>
<input type="url" id="url" name="url" placeholder="https://example.com/a-very-long-link" required>
<button type="submit">Shorten URL</button>
</form>
<% if links.any? %>
<h2>Created Links</h2>
<table>
<thead>
<tr>
<th>Short URL</th>
<th>Original URL</th>
<th>Clicks</th>
</tr>
</thead>
<tbody>
<% links.each do |slug, data| %>
<tr>
<td><a href="/<%= slug %>/info"><code>/<%= slug %></code></a></td>
<td><a href="<%= Rack::Utils.escape_html(data['url']) %>" title="<%= Rack::Utils.escape_html(data['url']) %>"><%= Rack::Utils.escape_html(data['url'][0..40]) %>...</a></td>
<td><%= data['clicks'] %></td>
</tr>
<% end %>
</tbody>
</table>
<% end %>

Note that this page now displays a table with the links that have been created so far.

Also, notice the use of Rack::Utils.escape_html for the URLs. This is crucial for security: it prevents Cross-Site Scripting (XSS) attacks by escaping special HTML characters.

Creating a Short URL

This action handles the data submitted from the home page form. Its responsibility is to validate the URL, save it, and redirect the user to a confirmation page.

Add the route to process the form:

app/shortener.rb
def route(request)
# Use pattern matching to decide what to do
case [request.request_method, request.path_info]
when ["GET", "/"]
render_home
when ["POST", "/shorten"]
create_short_url(request)
else
not_found
end
end

Now create the method that processes the creation:

app/shortener.rb
3 collapsed lines
def bad_request(message)
# ...
end
def create_short_url(request)
# Form data comes in request.params
original_url = request.params["url"]&.strip
# Basic validation
return bad_request("Invalid URL") if original_url.nil? || original_url.strip.empty?
original_url.strip!
# Save and get the slug
slug = @store.save(original_url)
# Redirect to the info page for the shortened URL
redirect("/#{slug}/info")
end
def valid_url?(url)
url&.match?(URL_REGEX)
end

The Redirects

This is the action that brings the shortener to life. When a user visits a short URL like http://localhost:9292/abcd, this method is executed to find the original URL and redirect the browser to it.

app/shortener.rb
3 collapsed lines
def valid_url?(url)
# ...
end
def redirect(location)
[302, { "Location" => location }, []]
end
def redirect_to_original(slug)
# Look for the record in our "database"
record = @store.find(slug)
# If it doesn't exist, return 404
return not_found unless record
# Increment the visit counter
@store.increment_clicks(slug)
# Redirect with a 302 (temporary redirect)
# Use 302 instead of 301 so we can count every visit
[302, { "Location" => record["url"], "Cache-Control" => "no-cache, no-store" }, []]
end

The redirect response is special: it has a 302 status code and a Location header that tells the browser where to go. The browser automatically makes a new request to that URL. The Cache-Control header prevents the browser from caching the redirect, allowing us to count every visit.

The Info Page

This action displays a confirmation page after a URL has been created. It provides the user with the short link and some additional information, like the click count.

Update the routing to capture routes like /abc123/info:

app/shortener.rb
def route(request)
case [request.request_method, request.path_info]
when ["GET", "/"]
render_home
when ["POST", "/shorten"]
create_short_url(request)
else
# Check if it's an info page
if request.get? && (match = request.path_info.match(/^\/([a-z0-9]{4})\/info$/))
slug = match[1]
render_info(slug)
# Or if it's a slug for redirection
elsif request.get? && request.path_info.match(/^\/([a-z0-9]{4})$/)
slug = request.path_info[1..-1]
redirect_to_original(slug)
else
not_found
end
end
end

Create the method to display the info page:

app/shortener.rb
3 collapsed lines
def redirect_to_original(slug)
# ...
end
def render_info(slug)
record = @store.find(slug)
return not_found unless record
render("info", slug: slug, data: record)
end
app/views/info.erb
<h2>URL shortened successfully!</h2>
<p>Your short link is: <a href="/<%= slug %>"><strong>http://localhost:9292/<%= slug %></strong></a></p>
<p>It redirects to: <a href="<%= Rack::Utils.escape_html(data['url']) %>"><%= Rack::Utils.escape_html(data['url']) %></a></p>
<p>This link has been visited <strong><%= data['clicks'] %></strong> times.</p>
<hr>
<a href="/">&larr; Back to home</a>

Restart your server and try shortening a URL.

Adding URL Validation

We don’t want users to be able to save any text as if it were a URL. Let’s add proper validation.

First, add the uri require to the top of the file so it looks like this:

app/shortener.rb
require "rack"
require "erb"
require "uri"
require_relative "store"

Now add a method to validate URLs:

app/shortener.rb
3 collapsed lines
def render_info(slug)
# ...
end
def valid_url?(url)
return false if url.nil? || url.empty?
# Try to parse the URL
uri = URI.parse(url)
# Verify that it's HTTP or HTTPS and has a host
(uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)) && !uri.host.nil?
rescue URI::InvalidURIError
false
end

Update the basic validation in create_short_url:

app/shortener.rb
def create_short_url(request)
# Form data comes in request.params
original_url = request.params["url"]&.strip
# Basic validation
return bad_request("Invalid URL") unless valid_url?(original_url)
original_url.strip!
# Save and get the slug
slug = @store.save(original_url)
# Redirect to the info page for the shortened URL
redirect("/#{slug}/info")
end

Creating a Logging Middleware

Let’s create a logger that prints information about each request to the console. A middleware is perfect for this, as it allows us to “wrap” our main application to add cross-cutting concerns without modifying the application code itself.

app/middleware/logger.rb
module URLShortener
class LoggerMiddleware
def initialize(app)
# Save the next app in the chain
@app = app
end
def call(env)
# Capture the start time
started_at = Time.now
# Extract useful info from the env
method = env['REQUEST_METHOD']
path = env['PATH_INFO']
# Call the next app and wait for its response
status, headers, body = @app.call(env)
# Calculate how long it took
duration = ((Time.now - started_at) * 1000).round(2)
# Choose a color based on the status code
color = case status
when 200..299 then 32 # Green for success
when 300..399 then 33 # Yellow for redirects
when 400..499 then 31 # Red for client errors
when 500..599 then 35 # Magenta for server errors
else 37 # White for others
end
# Build and display the log message
timestamp = Time.now.strftime('%H:%M:%S')
log_line = "[#{timestamp}] #{method.ljust(6)} #{path.ljust(20)} → #{status} (#{duration}ms)"
# Print with ANSI colors
puts "\e[#{color}m#{log_line}\e[0m"
# Return the response without modifying it
[status, headers, body]
end
end
end

This middleware intercepts every request, logs information about it, and then lets it continue on its way. The ANSI codes (\e[32m, etc.) add color to the terminal output, making the logs more readable.

Now update config.ru to use the middleware:

config.ru
require_relative 'app/shortener'
require_relative 'app/middleware/logger'
# We use Rack::Builder to build our middleware stack
app = Rack::Builder.new do
# Middlewares are executed in order
use URLShortener::LoggerMiddleware
# The main application goes at the end
run URLShortener::ShortenerApp.new
end
run app

When adding middleware to the stack, order matters: requests pass through them from top to bottom before reaching the application.

Restart the server and notice how every request is now logged in the terminal with colors.

Bonus: Optimizing Performance 🚀

Our application works, but we can make it faster and more efficient with a few standard HTTP techniques.

Compressing Responses with Gzip

Reducing the size of our HTML responses means they will reach the user’s browser faster. The Rack::Deflater middleware does this automatically.

Implementation

Add use Rack::Deflater to config.ru.

config.ru
require_relative 'app/shortener'
require_relative 'app/middleware/logger'
# We use Rack::Builder to build our middleware stack
app = Rack::Builder.new do
use Rack::Deflater
# Middlewares are executed in order
use URLShortener::LoggerMiddleware
# The main application goes at the end
run URLShortener::ShortenerApp.new
end
run app

How does it work?

  1. The user’s browser sends an Accept-Encoding: gzip, deflate header (among others).
  2. Rack::Deflater sees this header and, if the response is large enough, compresses it using Gzip.
  3. It adds the Content-Encoding: gzip header to the response and sends it.
  4. The browser receives the compressed response, decompresses it, and renders it. All of this is transparent to the user, but much faster!

Advanced Configuration: Selective Compression

It doesn’t make sense to try to compress resources that are already compressed (like JPG or PNG images) or very small responses. We can configure Rack::Deflater to only act under certain conditions.

use Rack::Deflater, if: -> (env, status, headers, body) {
# Only compress HTML responses with a body that can be iterated
headers['Content-Type']&.include?('text/html') && body.respond_to?(:each)
}

Conditional Requests (Caching)

Why send a full page if the user already has the latest version in their cache? Conditional requests allow us to respond with a 304 Not Modified, saving bandwidth. To do this, we’ll combine two middlewares: Rack::ETag and Rack::ConditionalGet.

Implementation

We add both middlewares to config.ru. The order is crucial.

config.ru
require_relative 'app/shortener'
require_relative 'app/middleware/logger'
# We use Rack::Builder to build our middleware stack
app = Rack::Builder.new do
use Rack::Deflater
use Rack::ConditionalGet
use Rack::ETag
# Middlewares are executed in order
use URLShortener::LoggerMiddleware
# The main application goes at the end
run URLShortener::ShortenerApp.new
end
run app

How do they work together?

  1. Rack::ETag: Its job is to generate a “fingerprint” (a hash) of the response body and add it as an ETag header.
  2. Rack::ConditionalGet: It acts as a gatekeeper. It compares the ETag header of the response with the If-None-Match header sent by the browser (if it has one). If they match, it stops the process and returns an empty 304 Not Modified.

Importance of Middleware Order

The flow of a response in Rack is “upward”, from the application to the server, in the reverse order they are declared in config.ru. The order is fundamental for middlewares to collaborate correctly.

With our configuration, the response flow is:

ShortenerApp -> Logger -> ETag -> ConditionalGet -> Deflater

  1. The response leaves ShortenerApp (and passes through Logger, which doesn’t modify it).
  2. Rack::ETag receives it. It calculates the ETag from the original, uncompressed body and adds the header.
  3. Rack::ConditionalGet receives it. It compares the newly added ETag with the one from the request. If they match, it returns a 304, and the flow for this response stops here. If not, it lets the full response pass through.
  4. Rack::Deflater receives it. If it’s a 304, it has no body and does nothing. If it’s a 200 OK, it compresses the body and updates the Content-Encoding header.

This is the correct order. If ETag were to run after Deflater, it would try to calculate a hash on an already compressed body, which wouldn’t work if the response is sent with Transfer-Encoding: chunked, as ETag needs to read the entire body to function.

In-Memory ERB Template Caching

Every time we call render, our application reads the .erb files from the disk. This can be slow. In a production environment, where files don’t change, we can read them just once when the application starts and store them in memory.

Implementation

Modify the ShortenerApp class to load the templates into constants.

app/shortener.rb
module URLShortener
class ShortenerApp
LAYOUT_TEMPLATE = ERB.new(File.read("#{__dir__}/views/layout.erb"))
HOME_TEMPLATE = ERB.new(File.read("#{__dir__}/views/home.erb"))
INFO_TEMPLATE = ERB.new(File.read("#{__dir__}/views/info.erb"))
# ... initialize, call, route ...
private
def render(template, locals = {}) # Changed view_name to template
view_content = template.result_with_hash(locals)
final_body = LAYOUT_TEMPLATE.result_with_hash(content: view_content)
ok_html(final_body)
end
def render_home
render(HOME_TEMPLATE, links: @store.all)
end
def render_info(slug)
record = @store.find(slug)
return not_found unless record
render(INFO_TEMPLATE, slug: slug, data: record)
end
# ... rest of the methods ...
end
end

How does it work?

By defining the templates as constants, the ERB.new(File.read(...)) code runs only once, when Ruby loads the ShortenerApp class as the server starts. From that point on, every call to render directly uses the already compiled ERB object stored in memory, completely avoiding costly disk-read operations on every request.

Conclusion

Congratulations! You have built a complete URL shortening service from scratch and now you also know how to optimize it. Along the way, you have learned to:

  1. Structure a Rack application without a framework.
  2. Create a robust, manual router.
  3. Manage requests and responses, including redirects.
  4. Use ERB to create dynamic and secure views.
  5. Implement simple persistence with JSON files.
  6. Add features and optimizations with the middlewares that come with Rack.

Test your knowledge

  1. What potential concurrency issue is mentioned regarding the simple Store class?

  1. How is Cross-Site Scripting (XSS) prevented when displaying user-provided URLs in the HTML views?

  1. What HTTP status code is used to redirect from a short URL to the original URL, and why?

  1. Which Rack middleware is used to automatically compress responses with Gzip?

  1. What is the purpose of the Rack::ETag middleware?

  1. How were the ERB view templates optimized for better performance in a production environment?