• Elixir
  • Phoenix
  • Ecto
  • AI
  • ChatGPT
Publish date:

15 min read

AI Driven Development Series: Create an URL Shortener in Elixir

Note on why AI in the name of the post

We'll be using a little bit of OpenAI's ChatGPT throughout the post, my intention was not to ask an AI to directly write our application nor to show you how you can do it but to walk you through an example of how a developer can chat with an AI to get help defining the steps to implement a feature or fix a bug. Also you don't need to pay the premium tier for this, everything we'll be asking should work the same in the free tier.

Generate the feature's brief

Turn the feature's brief into clear list of requirements, in this case the URL shortener feature. Open your favorite LLM chat and write the following prompt:

Here's the project brief: [I want to create an URL shortener feature in my blogsite to be able to share a shorter link of my blog posts on my social networks. The app is built with Elixir, Phoenix and LiveView]. Generate a list technical requirements for building this feature

ChatGPT response:

Here’s a list of technical requirements for building an URL shortener feature in your blog site using Elixir, Phoenix, and LiveView:

1. Core Functionality

  • URL Storage:

    • A database table to store original URLs, shortened URLs, and metadata.
    • Fields: id, original_url, short_url, created_at, expires_at (optional).
  • Short URL Generation:

    • A function to generate unique short strings (e.g., Base62 encoding, UUID, or hash-based).
    • Collision handling to ensure uniqueness of short_url.

2. Backend Implementation

  • Phoenix Context:
    • Create a context (e.g., UrlShortener) to encapsulate business logic.
    • Functions:
      • shorten_url/1: Accepts an original URL and returns a shortened URL.
      • resolve_url/1: Accepts a short URL and returns the corresponding original URL.
      • list_urls/0: Optionally lists all shortened URLs for the admin view.
    • Database Integration:
      • Use Ecto migrations to define the database schema.
      • Index the short_url field for fast lookups.

3. Frontend Implementation

  • LiveView Component:
    • A form to input the original URL and display the resulting short URL.
    • Realtime feedback on URL shortening and validation (e.g., invalid URLs).
    • A list of recently created short URLs for admin users or personal tracking.
    • Validation:
      • Client-side and server-side validation for URL format.

4. Redirection Logic

  • Phoenix Router:
    • Add a dynamic route to handle short URL requests.
    • Example: get "/:short_url", UrlShortenerController, :redirect.
  • Redirection Controller:
    • Fetch the original URL from the database using the short URL.
    • Handle edge cases like expired or non-existent URLs with appropriate HTTP responses (e.g., 404 or 410).

5. Expiration (Optional)

  • Optionally add an expiration mechanism:
    • expires_at column to set a TTL (Time to Live) for each short URL.
    • A background task (e.g., Oban or GenServer) to periodically clean up expired records.

6. Analytics (Optional)

  • Track usage metrics:
    • Store details like click_count, last_accessed_at, or referrer for each short URL.
    • Create a dashboard for admin users to view stats.

...

11. User Interface Enhancements

  • Provide a "Copy to Clipboard" button for the generated short URL.
  • Support QR code generation for the short URL (optional)

End of ChatGPT response.


Taking only what we need from the AI

For this exercise I'll take only what I think is the most important for the feature to live in the app that serves this blog.

Altough the technical requirements given by the LLM were quite extensive and lead you somewhere near to an MVP for this kind of service due to the fact that I'll be the only person to use this feature and won't be publicly available that makes it more simple and I can keep this post shorter (pun intended).

Based on the AI response and our specific needs, the following will be our north star:

  1. URL Storage: A database table to store original URLs, shortened URLs, and metadata.

  2. Short URL Generation: A function to generate unique short strings (e.g., Base62 encoding, UUID, or hash-based). Collision handling to ensure uniqueness of short_url.

  3. Database Integration: Use Ecto migrations to define the database schema. Index the short_url field for fast lookups.

  4. Phoenix Context: Create a context (e.g., UrlShortener) to encapsulate business logic.

  5. LiveView Component: A form to input the original URL and display the resulting short URL.

  6. Phoenix Router: Add a dynamic route to handle short URL requests. Example: get "/:short_url", UrlShortenerController, :redirect.

  7. Redirection Controller: Fetch the original URL from the database using the short URL. Handle edge cases like expired or non-existent URLs with appropriate HTTP responses (e.g., 404 or 410).

  8. User Interface Enhancements: Provide a "Copy to Clipboard" button for the generated short URL.

Building the feature

We'll start by creating a Phoenix Context, run the following command in your terminal:

mix phx.gen.context UrlShortener ShortenUrl shortened_urls original_url:text short_url:text

It should output something like this:

* creating lib/portfolio/url_shortener/shorten_url.ex
* creating priv/repo/migrations/20250103234214_create_shortened_urls.exs
* creating lib/portfolio/url_shortener.ex
* injecting lib/portfolio/url_shortener.ex
* creating test/portfolio/url_shortener_test.exs
* injecting test/portfolio/url_shortener_test.exs
* creating test/support/fixtures/url_shortener_fixtures.ex
* injecting test/support/fixtures/url_shortener_fixtures.ex

Remember to update your repository by running migrations:

    $ mix ecto.migrate

I decided to go with a context instead of the full phx.gen.live generator or from less with the phx.gen.schema or ecto.gen.migration because it's statys somewhat in the middle. My reasoning was that I need the migration to hold the table definition, an ecto schema that will interface with the DB and the context that will have the business logic of the feature, everything else like views, LiveViews, controllers, wasn't necessary as we'll be implementing just one LiveView.

The phx.gen.context creates a several files but the revelant ones are:

  • lib/portfolio/url_shortener/shorten_url.ex: This is the Ecto Schema
  • lib/portfolio/url_shortener/20250103234214_create_shortened_urls.ex: This is the migration file used to create the database table
  • lib/portfolio/url_shortener/url_shortener.ex: This is the actual Phoenix Context, when I mention the word context anywhere in the post you can assume I'm talking about this file.

The names I choose and how I used them can be a little bit confusing but I'll try to clear them up with the following table:

The keyword Refers to
short_url Database field
shortened_urls Database table
ShortenUrl Ecto schema
UrlShortener Phoenix context module

Everything in this post will be scoped to the Portfolio and PortfolioWeb module names as it's the name of the modules where this app lives, yours will be different, adjust it to your case.

1. Create the DB table

Open up the priv/repo/migrations/20250103234214_create_shortened_urls.exs file and add the following to the migration.

Note: The 20250103234214 part of the migration file for your migration must be different because it's a timestamp of when the migration was created.

defmodule Portfolio.Repo.Migrations.CreateShortenedUrls do
  use Ecto.Migration

  def change do
    create table(:shortened_urls, primary_key: false) do
      add :id, :binary_id, primary_key: true
      add :original_url, :text, null: false # <- Make non-nullable
      add :short_url, :string, null: false # <- Make non-nullable

      timestamps(type: :utc_datetime)
    end
  end
end

Then run database migration:

mix ecto.migrate

2. Implement shortening algorithm

We'll go for the base62 algorithm implementation, there are many resources that walk you through the different algorithms pros and cons, I've found this article very interesting and I used it as the base for my own implementation.

The first step is to install a library to handle base62 encoding. Elixir/Erlang have great support for base encodings like base16, base32 and base64 in the Base module but the one we need is not available.

Add the base62 library from hex to your mix.exs and run mix deps.get to install it.

defp deps do
  [
    ...

    # url shortener
    {:base62, "~> 1.2"}
  ]
end

Next, we'll implement a function that will be responsible of converting a fully qualified URL into a hash. It should be placed in your phoenix context, in the case of my app it's in lib/portfolio/url_shortener.ex

def url_to_hash(url) do
  :crypto.hash(:md5, url)
  |> :binary.part({0, 6})
  |> :binary.decode_unsigned()
  |> Base62.encode()
end

The function above implements the following steps of the algorithm in the article I mentioned:

  1. Create an MD5 hash of the long URL
  2. Take the first 6 bytes of the hash to create a more manageable short URL
  3. Convert these bytes into decimal
  4. Encode the result into a Base62 encoded string

If you were wondering what's up with :crypto and :binary instructions. They're calling functions from Erlang modules, that's the syntax we use in Elixir to leverage everything in the Erlang ecosystem!

3. Implement the business logic

For this step we'll be working with the context at: lib/portfolio/url_shortener.ex to implement the functions needed by the rest of the application.

def create_shorten_url(attrs \\ %{}) do
  attrs = put_short_url(attrs) # <- put short url before casting attributes

  %ShortenUrl{}
  |> ShortenUrl.changeset(attrs)
  |> Repo.insert()
end

...

def put_short_url(attrs) do
  case Map.get(attrs, "original_url") do
    # handles the case where the original URL is nil or an empty string
    nil ->
      attrs

    "" ->
      attrs

    original_url ->
      short_url = url_to_hash(original_url)
      Map.put(attrs, "short_url", short_url)
  end
end

It's the time to test what we have done, open an IEX session in your terminal with the following command:

iex -S mix

Once inside IEX write the following code:

alias Portfolio.UrlShortener # <- Your's might be different

url = "https://www.reddit.com/r/elixir/comments/1hl8hwi/comment/m3kz6qb/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button"

UrlShortener.create_shorten_url(%{"original_url" => url})

In case everything was setup correctly you should get something like this:

[debug] QUERY OK source="shortened_urls" db=6.7ms decode=0.9ms queue=1.0ms idle=1547.7ms
INSERT INTO "shortened_urls" ("original_url","short_url","inserted_at","updated_at","id") VALUES ($1,$2,$3,$4,$5) ["https://www.reddit.com/r/elixir/comments/1hl8hwi/comment/m3kz6qb/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button", "zCuT4APM", ~U[2025-01-07 01:53:29Z], ~U[2025-01-07 01:53:29Z], "37dd6304-5b6a-4a43-a02f-3fa2a9dd073f"]anonymous fn/4 in :elixir.eval_external_handler/1, at: src/elixir.erl:376

{:ok,
 %Portfolio.UrlShortener.ShortenUrl{
   __meta__: #Ecto.Schema.Metadata<:loaded, "shortened_urls">,
   id: "37dd6304-5b6a-4a43-a02f-3fa2a9dd073f",
   original_url: "https://www.reddit.com/r/elixir/comments/1hl8hwi/comment/m3kz6qb/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button",
   short_url: "zCuT4APM",
   inserted_at: ~U[2025-01-07 01:53:29Z],
   updated_at: ~U[2025-01-07 01:53:29Z]
 }}

At this point we have a way to create shortened URLs but we still need a way to resolve a short URL or hash into the original long URL. Let's work on that next.

Add the following function to your context:

 def resolve_url(hash) do
  case Repo.get_by(ShortenUrl, short_url: hash) do
    nil -> {:error, "URL not found"}
    shorten_url -> {:ok, shorten_url.original_url}
  end
end

If you want you can try to resolve the URL shortened by the step above:

iex(2)> UrlShortener.resolve_url("zCuT4APM")
[debug] QUERY OK source="shortened_urls" db=0.3ms queue=0.9ms idle=1007.8ms
SELECT s0."id", s0."original_url", s0."short_url", s0."inserted_at", s0."updated_at" FROM "shortened_urls" AS s0 WHERE (s0."short_url" = $1) ["zCuT4APM"]↳ Portfolio.UrlShortener.resolve_url/1, at: lib/portfolio/url_shortener.ex:41

{:ok,
 "https://www.reddit.com/r/elixir/comments/1hl8hwi/comment/m3kz6qb/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button"}

4. Working with the client

One thing I really like about the Context abstraction that Phoenix has is that they hold agnostic code oblivious of any client we may have. It doesn't matter to the context functions if the client is a REST API, a GraphQL API, a CLI tool or anything else. It's a construct other MVC frameworks doesn't have by default and it can be really useful in the long term. As with everything, we still need to be really careful of how we do things because nothing stops us from doing client-specific things inside our context functions.

It's time to create the controller that will be resposible of redirecting the request to the original URL.

Create it under lib/portfolio_web/controllers/url_shortener_controller.ex:

defmodule PortfolioWeb.UrlShortenerController do
  use PortfolioWeb, :controller
end

Add the route to router, in order to avoid having a route that can match any path I decided to add the /s/ part so the shortened URLs have the following form: ivanmunguia.dev/s/[some-hash]

get "/s/:short_url", UrlShortenerController, :redirect

Then we can implement the happy path:

defmodule PortfolioWeb.UrlShortenerController do
  use PortfolioWeb, :controller

  alias Portfolio.UrlShortener

  def redirect_to(conn, %{"short_url" => hash}) do
    case UrlShortener.resolve_url(hash) do
      {:ok, url} ->
        redirect(conn, external: url)
    end
  end
end

With the code above our application can now redirect our users from shortened URLs to the original URLs but we lack some robustness in the code, what will happen if the hash doesn't exist?

Let's work on that and implement the not so happy path. For this I wanted to implement a 404 page to let the user know that the page was not found and go back to the main page.

def redirect_to(conn, %{"short_url" => hash}) do
  case UrlShortener.resolve_url(hash) do
    {:error, _reason} ->
      conn
      |> put_status(404)
      |> put_view(html: PortfolioWeb.ErrorHTML)
      |> render(:"404")

    {:ok, url} ->
      redirect(conn, external: url)
  end
end

For the code above to work you need to create an ErrorHTML module if you don't have one already.

defmodule PortfolioWeb.ErrorHTML do
  @moduledoc """
  This module is invoked by your endpoint in case of errors on HTML requests.

  See config/config.exs.
  """
  use PortfolioWeb, :html

  embed_templates "error_html/*"

  # If you want to customize your error pages,
  # uncomment the embed_templates/1 call below
  # and add pages to the error directory:
  #
  #   * lib/portfolio_web/controllers/error_html/404.html.heex
  #   * lib/portfolio_web/controllers/error_html/500.html.heex
  #
  # embed_templates "error_html/*"

  # The default is to render a plain text page based on
  # the template name. For example, "404.html" becomes
  # "Not Found".
  def render(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end

And we need to create the error_html directory that will be responsible of serving the 404 HTML template:

mkdir lib/portfolio_web/controllers/error_html
touch lib/portfolio_web/controllers/error_html/404.html.heex

Inside the 404.html.heex file add the following:

<!DOCTYPE html>
<html lang="en" class="dark">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Portfolio | Page not found</title>
    <link rel="stylesheet" href="/assets/app.css" />
  </head>
  <body class="h-screen w-screen dark:bg-neutral-900 bg-white">
    <main class="max-w-2xl m-auto py-32 flex flex-col items-center">
      <h1 class="text-2xl font-bold tracking-tight text-neutral-900 dark:text-white sm:text-4xl text-center">
        Sorry, the page you are looking for does not exist.
      </h1>
      <button
        id="go-back"
        type="button"
        class="mt-12 text-md text-neutral-500 hover:text-blue-400 dark:text-neutral-50 duration-200"
      >
        ← Go to home page
      </button>
    </main>
    <script>
      const button = document.getElementById("go-back")
      button.addEventListener("click", () => {
        window.location.href = '<%= PortfolioWeb.Endpoint.url() %>'
      }); 
    </script>
  </body>
</html>

5. Create the LiveView

It has been a long run since we started defining the implementation steps but we're near the end. Next we need a way to interact with the feature so we'll be using LiveView.

The LiveView should be responsible of receiving the original URL as input and displaying the generated shortened URL to the user.

Create the file under lib/portfolio_web/live/url_shortener_live.ex and add the following base code:

defmodule PortfolioWeb.UrlShortenerLive do
  use PortfolioWeb, :live_view

  alias Portfolio.UrlShortener

  @impl true
  def render(assigns) do
    ~H"""
    <section>
      <div class="mx-auto max-w-4xl px-8 py-16">
        <h1 class="text-2xl font-bold tracking-tight text-neutral-900 dark:text-white sm:text-4xl">
          Shorten your URLs
        </h1>

        <.form for={@form} class="flex flex-row items-center" phx-submit="shorten">
          <.input type="text" field={@form[:original_url]} />
          <button
            type="submit"
            class="mt-2 ml-4 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
          >
            Shorten!
          </button>
        </.form>
      </div>
    </section>
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, form: to_form(%{"original_url" => ""}))}
  end

  @impl true
  def handle_event("shorten", params, socket) do
    shortened_url = UrlShortener.create_shorten_url(params)

    IO.inspect(shortened_url)

    {:noreply, socket}
  end
end

The LiveView above has the UI but lacks the functionality, let's implement it:

defmodule PortfolioWeb.UrlShortenerLive do
  use PortfolioWeb, :live_view

  alias Portfolio.UrlShortener

  @impl true
  def render(assigns) do
    ~H"""
    <section class="bg-white">
      <div class="mx-auto max-w-4xl px-8 py-16">
        <h1 class="text-2xl font-bold tracking-tight text-neutral-900">
          Shorten your URLs
        </h1>

        <.form for={@form} class="flex flex-row items-center" phx-submit="shorten">
          <.input type="text" field={@form[:original_url]} placeholder="Provide a long URL" />
          <button
            type="submit"
            class="mt-2 ml-4 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
          >
            Shorten!
          </button>
        </.form>

        <div :if={@short_url} class="mt-4 border border-gray-300 rounded-md py-2 px-4">
          <span class="text-gray-700"><%= @short_url %></span>
        </div>
      </div>
    </section>
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    {:ok, assign(socket, form: to_form(%{"original_url" => ""}), short_url: nil)}
  end

  @impl true
  def handle_event("shorten", params, socket) do # <- Handle the generation of a shortened URL
    {:ok, shortened_url} = UrlShortener.create_shorten_url(params)

    socket =
      socket
      |> assign_short_url(shortened_url)
      |> put_flash(:info, "URL shortened successfully")

    {:noreply, socket}
  end

  defp assign_short_url(socket, %{short_url: hash}) do
    # builds the shortened URL based on the configured base URL where the application lives
    assign(socket, short_url: PortfolioWeb.Endpoint.url() <> "/s/#{hash}")
  end
end

And that's it! Don't forget to add a new route to your router to render the LiveView. Because the application where my blog lives doesn't have authentication and authorization I didn't add it as a new route but you can specify any path you want for it.

Final result:

Video of URL Shortener

Bonus

As for the bonus I tought it would be interesting to cover the handling of duplicated URLs.

Let's start by adding an unique index to the short_url colum of the shortened_urls table. First we need to create a migration:

mix ecto.gen.migration add_unique_index_to_shortened_rls

Then add the index

defmodule Portfolio.Repo.Migrations.AddUniqueIndexToShortenedUrls do
  use Ecto.Migration

  def change do
    create unique_index(:shortened_urls, [:short_url])
  end
end

Now if you try to shorten the same URL two times you'll get the following error:

[error] GenServer #PID<0.898.0> terminating
** (Ecto.ConstraintError) constraint error when attempting to insert struct:

    * "shortened_urls_short_url_index" (unique_constraint)

To continue the flow of using AI as our guide to develop this feature let's ask ChatGPT how can fix this error.

I'm getting the following error in Ecto due to a unique index for the short_url column in the DB, how can I handle the error gracefully in my liveview to prevent the process from crashing?

ChatGPT response:

To handle the unique constraint error for the short_url column gracefully in your LiveView and prevent the process from crashing, you can take the following steps:

  1. Capture and Handle the Error in Ecto Wrap your database insert/update operation with a try...rescue block or use Ecto.Changeset's constraint validation features.

Example with Changeset: Ensure your schema has a unique_constraint defined for the short_url field:

defmodule MyApp.UrlShortener do
  use Ecto.Schema
  import Ecto.Changeset

  schema "urls" do
    field :original_url, :string
    field :short_url, :string
    timestamps()
  end

  def changeset(url, attrs) do
    url
    |> cast(attrs, [:original_url, :short_url])
    |> validate_required([:original_url, :short_url])
    |> unique_constraint(:short_url, message: "Short URL already exists.")
  end
end
  1. Use the Changeset in the LiveView When processing form submissions in your LiveView, handle the unique_constraint error gracefully:
def handle_event("submit", %{"url" => url_params}, socket) do
  changeset = UrlShortener.changeset(%UrlShortener{}, url_params)

  case MyApp.Repo.insert(changeset) do
    {:ok, _url} ->
      {:noreply, assign(socket, success_message: "URL shortened successfully!", changeset: nil)}

    {:error, changeset} ->
      # Handle the unique constraint error here
      {:noreply, assign(socket, changeset: changeset, error_message: "Could not shorten URL. Please try again.")}
  end
end

End of ChatGPT response.


As we can see, it recommended a few ways we can solve the problem, the original response even included two more alternatives that I ommited so we can stay on track and implement the first one to keep it simple.

Add the unique_constraint to the lib/portfolio/url_shortener/shorten_url.ex Ecto schema:

def changeset(shorten_url, attrs) do
  shorten_url
  |> cast(attrs, [:original_url, :short_url])
  |> validate_required([:original_url, :short_url])
  |> unique_constraint(:short_url, message: "Short URL already exists.") # <- add the new constraint
end

Then inside the LiveView add the following to our handle_event and that's it!

 @impl true
def handle_event("shorten", params, socket) do
  socket =
    case UrlShortener.create_shorten_url(params) do
      {:error, _changeset} ->
        socket |> put_flash(:error, "Short URL already exists.")

      {:ok, shortened_url} ->
        socket
        |> assign_short_url(shortened_url)
        |> put_flash(:info, "URL shortened successfully")
    end

  {:noreply, socket}
end

Conclusion

By combining Elixir, Phoenix, and LiveView with a touch of AI-driven guidance, we’ve successfully built a functional and efficient URL shortener. This project showcases how AI tools like ChatGPT can assist in breaking down requirements, suggesting approaches, and even resolving challenges like database constraints—all while leaving room for human decisions and refinements.

I hope this walkthrough inspires you to experiment with AI-driven development and leverage Elixir’s capabilities to build powerful, scalable features. As always, I’d love to hear your thoughts, feedback, or stories of implementing similar projects—feel free to reach out or share your experiences. Until next time, happy coding!

Share this post