DEV Community

Davide Santangelo
Davide Santangelo

Posted on

Building a simple URL Shortener with Rails 8: A Step-by-Step Guide

In this tutorial, we'll walk through creating a modern URL shortener application called "YLL" (Your Link Shortener) using Rails 8. By the end, you'll have a fully functional application with features like link expiration and password protection.

Introduction

URL shorteners are web applications that create shorter aliases for long URLs. When users access these short links, they are redirected to the original URL. Our application, YLL, will provide:

  • Short, unique codes for any URL
  • Optional expiration dates
  • Password protection
  • Click tracking
  • A full JSON API

Prerequisites

  • Ruby 3.4.2
  • Rails 8.0.1
  • Basic knowledge of Ruby on Rails

Project Setup

Let's start by creating a new Rails 8 application:

# Install the latest Rails version if you haven't already
gem install rails -v 8.0.1

# Create a new Rails application
rails new yll --database=sqlite3

# Navigate to the project directory
cd yll
Enter fullscreen mode Exit fullscreen mode

Database Design

Our application revolves around a single model: Link. Let's create it:

rails generate model Link url:string code:string:index password_digest:string expires_at:datetime clicks:integer
Enter fullscreen mode Exit fullscreen mode

Now let's run the migration:

rails db:migrate
Enter fullscreen mode Exit fullscreen mode

The Link Model

Edit the app/models/link.rb file to implement our model's business logic:

class Link < ApplicationRecord
  has_secure_password validations: false

  # Validations
  validates :url, presence: true,
                  format: {
                    with: URI::DEFAULT_PARSER.make_regexp(%w[http https]),
                    message: "must be a valid HTTP/HTTPS URL"
                  }

  validates :code, presence: true,
                   uniqueness: true,
                   length: { is: 8 }

  validate :validate_url_security
  validate :validate_url_availability, if: -> { url.present? && errors[:url].none? }
  validate :expires_at_must_be_in_future, if: -> { expires_at.present? }

  # Callbacks
  before_validation :normalize_url
  before_validation :generate_unique_code, on: :create

  def to_param
    code
  end

  def to_json(*)
    {
      original_url: url,
      short_url: short_url,
      created_at: created_at,
      expires_at: expires_at,
      code: code,
      clicks: clicks
    }.to_json
  end

  def expired?
    expires_at.present? && expires_at <= Time.current
  end

  def short_url
    Rails.application.routes.url_helpers.redirect_url(code)
  end

  private

  def normalize_url
    return if url.blank?

    begin
      uri = Addressable::URI.parse(url).normalize
      self.url = uri.to_s
    rescue Addressable::URI::InvalidURIError => e
      errors.add(:url, "contains invalid characters or format", e.message)
    end
  end

  def generate_unique_code
    self.code ||= loop do
      random_code = SecureRandom.alphanumeric(8)
      break random_code unless self.class.exists?(code: random_code)
    end
  end

  def validate_url_security
    return if errors[:url].any?

    uri = URI.parse(url)
    errors.add(:url, "must use HTTPS protocol") unless uri.scheme == "https"
  rescue URI::InvalidURIError
    # Already handled by format validation
  end

  def validate_url_availability
    response = Faraday.head(url) do |req|
      req.options.open_timeout = 3
      req.options.timeout = 5
    end

    unless response.success? || response.status == 301 || response.status == 302
      errors.add(:url, "could not be verified (HTTP #{response.status})")
    end
  rescue Faraday::Error => e
    errors.add(:url, "could not be reached: #{e.message}")
  end

  def expires_at_must_be_in_future
    errors.add(:expires_at, "must be in the future") if expires_at <= Time.current
  end
end
Enter fullscreen mode Exit fullscreen mode

Setting Up Dependencies

Add the required gems to your Gemfile:

# Add these to your Gemfile
gem "addressable", "~> 2.8"
gem "faraday", "~> 2.7"
gem "bcrypt", "~> 3.1.16"
Enter fullscreen mode Exit fullscreen mode

Then install the gems:

bundle install
Enter fullscreen mode Exit fullscreen mode

Creating Controllers

Redirects Controller

First, let's create the controller that will handle the redirection:

rails generate controller Redirects show
Enter fullscreen mode Exit fullscreen mode

Now, edit app/controllers/redirects_controller.rb:

class RedirectsController < ApplicationController
  rescue_from ActiveRecord::RecordNotFound, with: :link_not_found
  before_action :set_link, only: :show
  before_action :authenticate, only: :show, if: -> { @link.password_digest.present? }
  after_action :increment_clicks, only: :show, if: -> { response.status == 302 }

  def show
    if @link.expired?
      render file: Rails.root.join("public", "410.html"), status: :gone, layout: false
    else
      # Brakeman: ignore
      redirect_to @link.url, allow_other_host: true
    end
  end

  private

  def authenticate
    authenticate_or_request_with_http_basic("Links") do |username, password|
      username == @link.code && @link.authenticate(password)
    end
  end

  def increment_clicks
    @link.increment!(:clicks)
  end

  def set_link
    @link = Link.find_by!(code: params[:code])
  end

  def link_not_found
    render json: { error: "Link not found" }, status: :not_found
  end
end
Enter fullscreen mode Exit fullscreen mode

API Controller

Now, let's create the API controller for programmatic access:

rails generate controller Api::V1::Links create show
Enter fullscreen mode Exit fullscreen mode

Edit app/controllers/api/v1/links_controller.rb:

module Api
  module V1
    class LinksController < ApplicationController
      rate_limit to: 10, within: 3.minutes, only: :create, with: -> { render_rejection :too_many_requests }
      protect_from_forgery with: :null_session

      # POST /api/v1/links
      def create
        link = Link.new(link_params)
        if link.save
          render json: link.to_json, status: :created
        else
          render json: { errors: link.errors.full_messages }, status: :unprocessable_entity
        end
      end

      # GET /api/v1/links/:code
      def show
        link = Link.find_by(code: params[:code])
        if link
          render json: link.to_json
        else
          render json: { error: "Link not found" }, status: :not_found
        end
      end

      private

      def link_params
        params.permit(:url, :password, :expires_at)
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Setting Up Routes

Edit config/routes.rb to define our application routes:

Rails.application.routes.draw do
  # API routes
  namespace :api do
    namespace :v1 do
      resources :links, only: [:create, :show], param: :code
    end
  end

  # Redirect route
  get 'r/:code', to: 'redirects#show', as: :redirect

  # Root route (for a future web interface)
  root 'links#new'
end
Enter fullscreen mode Exit fullscreen mode

Application Controller

Update app/controllers/application_controller.rb to handle cache control and modern browsers:

class ApplicationController < ActionController::Base
  # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
  allow_browser versions: :modern

  protect_from_forgery with: :exception, unless: -> { request.format.json? }
  before_action :set_cache_control_headers

  private

  def set_cache_control_headers
    response.headers["Cache-Control"] = "no-store"
  end
end
Enter fullscreen mode Exit fullscreen mode

Basic Web Interface

Let's create a simple web interface for creating links. First, generate a Links controller for the web interface:

rails generate controller Links new create
Enter fullscreen mode Exit fullscreen mode

Edit app/controllers/links_controller.rb:

class LinksController < ApplicationController
  def new
    @link = Link.new
  end

  def create
    @link = Link.new(link_params)

    if @link.save
      redirect_to link_path(@link.code), notice: 'Link successfully created!'
    else
      render :new, status: :unprocessable_entity
    end
  end

  def show
    @link = Link.find_by!(code: params[:code])
  end

  private

  def link_params
    params.require(:link).permit(:url, :password, :expires_at)
  end
end
Enter fullscreen mode Exit fullscreen mode

Now create the views:

<!-- app/views/links/new.html.erb -->
<h1>Create a Short Link</h1>

<%= form_with(model: @link, url: links_path) do |form| %>
  <% if @link.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(@link.errors.count, "error") %> prohibited this link from being saved:</h2>
      <ul>
        <% @link.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= form.label :url %>
    <%= form.url_field :url, required: true %>
  </div>

  <div class="field">
    <%= form.label :password, "Password (optional)" %>
    <%= form.password_field :password %>
  </div>

  <div class="field">
    <%= form.label :expires_at, "Expiration (optional)" %>
    <%= form.datetime_local_field :expires_at %>
  </div>

  <div class="actions">
    <%= form.submit "Create Short Link" %>
  </div>
<% end %>
Enter fullscreen mode Exit fullscreen mode
<!-- app/views/links/show.html.erb -->
<h1>Your Short Link</h1>

<div>
  <p><strong>Original URL:</strong> <%= @link.url %></p>
  <p><strong>Short URL:</strong> <a href="<%= @link.short_url %>"><%= @link.short_url %></a></p>
  <p><strong>Created:</strong> <%= @link.created_at.to_s(:long) %></p>

  <% if @link.expires_at.present? %>
    <p><strong>Expires:</strong> <%= @link.expires_at.to_s(:long) %></p>
  <% end %>

  <% if @link.password_digest.present? %>
    <p><strong>Password Protected:</strong> Yes</p>
  <% end %>

  <p><strong>Clicks:</strong> <%= @link.clicks %></p>
</div>

<div>
  <%= link_to "Create Another Link", new_link_path %>
</div>
Enter fullscreen mode Exit fullscreen mode

Adding Error Pages

Create custom error pages:

<!-- public/404.html -->
<!DOCTYPE html>
<html>
<head>
  <title>Link not found (404)</title>
  <style>
    /* Your styling here */
  </style>
</head>
<body>
  <div class="error-page">
    <h1>404 - Link Not Found</h1>
    <p>The requested link does not exist.</p>
    <a href="/">Back to Home</a>
  </div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode
<!-- public/410.html -->
<!DOCTYPE html>
<html>
<head>
  <title>Link has expired (410)</title>
  <style>
    /* Your styling here */
  </style>
</head>
<body>
  <div class="error-page">
    <h1>410 - Link Expired</h1>
    <p>The requested link has expired and is no longer available.</p>
    <a href="/">Back to Home</a>
  </div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Testing the Application

Start your Rails server:

rails server
Enter fullscreen mode Exit fullscreen mode

Visit http://localhost:3000 in your browser to use the web interface, or use the API to create links programmatically:

# Creating a link via API
curl -X POST "http://localhost:3000/api/v1/links" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "password": "mysecurepassword",
    "expires_at": "2025-12-31T23:59:59Z"
  }'
Enter fullscreen mode Exit fullscreen mode

Security Considerations

Our application includes several security features:

  1. HTTPS Enforcement: Only HTTPS URLs are allowed
  2. URL Validation: URLs are validated for format and availability
  3. Password Protection: Links can be password-protected
  4. Rate Limiting: Prevents abuse of the API
  5. Modern Browser Requirement: Only modern browsers are supported

Docker and Deployment

YLL includes Docker support for easy deployment. Here's a basic Dockerfile:

# syntax=docker/dockerfile:1
# check=error=true

# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.2
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base

# Rails app lives here
WORKDIR /rails

# Install base packages
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y curl libjemalloc2 libvips sqlite3 && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Set production environment
ENV RAILS_ENV="production" \
    BUNDLE_DEPLOYMENT="1" \
    BUNDLE_PATH="/usr/local/bundle" \
    BUNDLE_WITHOUT="development"

# Throw-away build stage to reduce size of final image
FROM base AS build

# Install packages needed to build gems
RUN apt-get update -qq && \
    apt-get install --no-install-recommends -y build-essential git pkg-config && \
    rm -rf /var/lib/apt/lists /var/cache/apt/archives

# Install application gems
COPY Gemfile Gemfile.lock ./
RUN bundle install && \
    rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
    bundle exec bootsnap precompile --gemfile

# Copy application code
COPY . .

# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/

# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile

# Final stage for app image
FROM base

# Copy built artifacts: gems, application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails

# Run and own only the runtime files as a non-root user for security
RUN groupadd --system --gid 1000 rails && \
    useradd rails --uid 1000 --gid 1000 --create-home --shell /bin/bash && \
    chown -R rails:rails db log storage tmp
USER 1000:1000

# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]

# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server"]
Enter fullscreen mode Exit fullscreen mode

You'll also need a docker-entrypoint script to prepare the database:

#!/bin/bash

set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f tmp/pids/server.pid

# Run database migrations
if [ -f db/schema.rb ]; then
  echo "Running database migrations..."
  bundle exec rails db:migrate
fi

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
Enter fullscreen mode Exit fullscreen mode

Don't forget to make it executable:

chmod +x bin/docker-entrypoint
Enter fullscreen mode Exit fullscreen mode

Deployment with Kamal

YLL supports deployment with Kamal, a modern deployment tool for Ruby on Rails applications:

  1. Install the Kamal gem:
gem install kamal
Enter fullscreen mode Exit fullscreen mode
  1. Initialize Kamal in your project:
kamal init
Enter fullscreen mode Exit fullscreen mode
  1. Edit the generated config/deploy.yml file:
service: yll
image: username/yll
registry:
  username: registry_username
  password:
    - KAMAL_REGISTRY_PASSWORD
servers:
  web:
    hosts:
      - your-server-ip
    labels:
      traefik.http.routers.yll.rule: Host(`yll.yourdomain.com`)
env:
  clear:
    DATABASE_URL: postgres://username:password@db-host/yll_production
    RAILS_ENV: production
    HOST_URL: https://yll.yourdomain.com
volumes:
  - /path/on/host/storage:/rails/storage
Enter fullscreen mode Exit fullscreen mode
  1. Deploy your application:
kamal setup
kamal deploy
Enter fullscreen mode Exit fullscreen mode

Code on GitHub

The complete source code for YLL is hosted on GitHub and serves as a valuable resource for anyone looking to understand how the application works, study its structure, or contribute to its development.

You can access the repository here: Yll

Conclusion

You've successfully built a modern URL shortener application with Ruby on Rails 8! YLL provides a solid foundation that you can extend with more features like:

  • A more polished web interface
  • User accounts to manage links
  • Advanced analytics
  • QR code generation for shortened links
  • Browser extensions

The complete code for this project is available on GitHub, and you can use it as a starting point for your own URL shortener service.

Resources

Top comments (0)