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 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 Now let's run the migration: rails db:migrate The Link Model Edit the app/models/link.rb file to implement our model's business logic: class Link { 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 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

Feb 27, 2025 - 09:35
 0
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

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

Now let's run the migration:

rails db:migrate

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

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"

Then install the gems:

bundle install

Creating Controllers

Redirects Controller

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

rails generate controller Redirects show

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

API Controller

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

rails generate controller Api::V1::Links create show

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

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

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

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

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

Now create the views:


Create a Short Link

<%= form_with(model: @link, url: links_path) do |form| %> <% if @link.errors.any? %> id="error_explanation">

<%= pluralize(@link.errors.count, "error") %> prohibited this link from being saved:

    <% @link.errors.each do |error| %>
  • <%= error.full_message %>
  • <% end %>
<% end %> class="field"> <%= form.label :url %> <%= form.url_field :url, required: true %>
class="field"> <%= form.label :password, "Password (optional)" %> <%= form.password_field :password %>