We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
- Elixir
- Phoenix
- Ecto
- AI
- ChatGPT
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:
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.
- Create a context (e.g.,
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.
- Store details like
...
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:
-
URL Storage: A database table to store original URLs, shortened URLs, and metadata.
-
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.
-
Database Integration: Use Ecto migrations to define the database schema. Index the short_url field for fast lookups.
-
Phoenix Context: Create a context (e.g., UrlShortener) to encapsulate business logic.
-
LiveView Component: A form to input the original URL and display the resulting short URL.
-
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).
-
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:
- Create an MD5 hash of the long URL
- Take the first 6 bytes of the hash to create a more manageable short URL
- Convert these bytes into decimal
- 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:
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.
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:
- 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
- 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!