RSS Amplifier

DevelClan · Aug 9, 2025

Build Your Own URL Shortener with Rack

0
Sign in to vote or save

David Morales · DevelClan

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
Table of Contents

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.

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:

  • How the env Hash flows through the request lifecycle.
  • Creating and chaining middlewares with Rack::Builder.
  • Manual routing without external dependencies.
  • Lightweight persistence in a JSON file and its limitations.
  • Basic security best practices, like preventing XSS.
  • Performance optimization techniques using Rack middlewares.

Setting Up the Environment

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

mkdir url-shortener

cd url-shortener

Now, initialize Bundler to manage our dependencies:

bundle init

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

bundle add rack puma

Finally, create the directory structure we’ll use:

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.

  • This class handles all data operations: saving, finding, updating, and listing.
  • The goal of the initializer is to ensure our “database” (the db.json file) is ready to use by loading its contents into memory.

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

  • DB_FILE: We define a constant for the filename. This is a good practice to avoid “magic strings” scattered throughout the code.
  • initialize: This method runs every time we create a new instance with Store.new. It first checks if db.json exists. If not, it creates it with an empty JSON object ({}). Then, it reads the file’s content, parses it as JSON, and saves it to the instance variable @data, which will be a simple Ruby Hash.
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.

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.

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.

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

  • The generate_slug method uses an infinite loop that only breaks when it finds a slug that doesn’t already exist in our data. With 4 alphanumeric characters, we have thousands of possible combinations, so collisions are very unlikely.
  • The persist! method writes the changes to the db.json file.

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

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.

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

  • render(view_name, locals = {}): This is a very powerful helper method. Its job is to take the name of a view, load it along with the layout.erb template, and render the view inside the layout. The locals variable allows us to pass data from our action to the view (like the list of links).
  • render_home: This is the specific action for the / route. It simply calls our render helper, tells it to use the “home” view, and passes it all the links it gets from @store.
  • ok_html and not_found: These are helpers for generating standard Rack responses. Remember that a Rack response is always a three-element array: [status_code, headers, [body]].

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

  • <%= content %>: This is where ERB will insert the content of our specific view (for instance home.erb), which we pass from the render method.

<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:

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:

<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:

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:

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

  • request.params["url"]: Rack::Request automatically parses the form data (in this case, the field with name="url") and makes it available in the params hash.
  • valid_url?: A small validation helper that uses a regular expression to check if the string looks like an HTTP or HTTPS URL. We’ll define URL_REGEX later.
  • redirect(...): We implement the POST-Redirect-GET pattern. After successfully processing a POST request, we never return HTML directly. Instead, we redirect the user to a new page (in this case, the info page). This prevents the form from being resubmitted if the user reloads the page.

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.

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:

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:

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

  • This action is very simple: it looks up the slug’s data and, if found, reuses our render helper to display the info.erb view, passing it the necessary data for the template to display.

<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:

require "rack"

require "erb"

require "uri"

require_relative "store"

Now add a method to validate URLs:

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:

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.

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:

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.

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)

}

  • if: ->(...): We pass a lambda (an anonymous function) to the :if option. Rack::Deflater will execute this lambda just before deciding whether to compress.
  • headers['Content-Type']&.include?('text/html'): This is the main condition. We use the safe navigation operator (&.) to avoid errors if the Content-Type header doesn’t exist. We only proceed if the response is text/html.
  • body.respond_to?(:each): It’s a good security practice to check that the response body is an iterable object. Rack::Deflater needs to iterate over the body to process it.

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.

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.

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?

Read the original on develclan.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.