Implement HTML redirects with phoenix plug

This post was updated 27 Sep

In this tutorial, I will go through how you can add redirects to your Phoenix application. The goal is to have a CRUD interface in my admin where I can go in and setup a redirect path for a certain path. I should also be able to automatically add query params and chose if the redirect shuld be permanent or temporary.

Step 1 - Generate the Admin CRUD for managing redirects

In my example I am using the SAAS StarterKit boilerplate, that you can download for free here: https://livesaaskit.com/starterkit/new

That comes with a resource generator so I can run this command:

mix saaskit.gen.resource Redirects Redirect redirects path redirect_to type

NOTE: This is a simple feature and I only need the three columns.

And when I get prompted with adding an admin CRUD, I answer yes.

Select Y to add admin interface<br>

Select Y to add admin interface<br>

When the generator has run, I get prompted with adding the admin routes in the routes file. So, I need to open up the router and paste these in:

scope "/admin", ExampleWeb.Admin, as: :admin do
  pipe_through :browser

  live "/redirects", RedirectLive.Index, :index
  live "/redirects/new", RedirectLive.Index, :new
  live "/redirects/:id/edit", RedirectLive.Index, :edit

  live "/redirects/:id", RedirectLive.Show, :show
  live "/redirects/:id/show/edit", RedirectLive.Show, :edit

After I change the routes file and before I run the migrations, I need to modify the migration file. Since I now that I need to make lookups in the database based on the path, and the path should be unique, I want to add a unique-index. This ensures faster lookups and that a path can only have one redirect.

create unique_index(:redirects, :path)

When that is done, I can run the migrations

mix ecto.migrate

Next thing I need to do is to modify the Schema file and the changeset pipeline. I want to change this to use Ecto enum because I store the type of redirect in the type column and it can only have one of two values that corresponds to Plugs status codes. That is  :moved_permanently or :temporary_redirect

NOTE: The type of redirect is important for SEO if its an external page that is scraped by Google.

So, I can change the type field to be an Ecto Enum and will also set a default.

# lib/example/redirects/redirect.ex
field :type, Ecto.Enum, values: [:moved_permanently, :temporary_redirect], default: :moved_permanently

Next thing I want to do is to make sure that all paths starts with a slash. So I will add a function in the changeset pipeline that looks for paths that doesnt start with a slash and just adds it. IMO, this is a better user experience than  to have a validation error.

# lib/example/redirects/redirect.ex
def changeset(redirect, attrs) do
  |> cast(attrs, [:path, :redirect_to, :type])
  |> validate_required([:path, :redirect_to, :type])
  |> unique_constraint(:path, name: :redirects_path_index)
  |> maybe_add_initial_slash(:path)
  |> maybe_add_initial_slash(:redirect_to)

defp maybe_add_initial_slash(changeset, attr) do
  case get_change(changeset, attr) do
    nil -> changeset
    "/" <> _path_string -> changeset
    path_string -> put_change(changeset, attr, "/#{path_string}"   

I also need an easy way to find the redirect in the database by path so I can add this function in my redirects context:

# lib/example/redirects.ex
def get_redirect_by_path(path) do
  Repo.get_by(Redirect, path: path)

NOTE: This should return nil if a redirect is not found.

STEP 2 - Add the redirects plug

With this in place, I can now add the redirects plug.

# lib/example_web/plugs/redirects.ex
defmodule ExampleWeb.Plugs.Redirects do
  @moduledoc """
  This plug is responsible for maybe redirecting requests from one path
  to another.

  def init(options), do: options

  def call(conn, _opts) do
    with "GET" <- conn.method,
         %{} = redirect <- get_redirect(conn.request_path) do

      redirect_to =
        |> maybe_put_query_string(conn.query_string)
        |> URI.to_string()

      |> Plug.Conn.put_status(redirect.type)
      |> Phoenix.Controller.redirect(to: redirect_to)
      |> Plug.Conn.halt()
      _ ->

  defp get_redirect(path) do

  defp maybe_put_query_string(uri, ""), do: uri
  defp maybe_put_query_string(uri, query_string) do
    struct!(uri, query: query_string)

And I can test the plug with:

# test/example_web/plugs/redirects_test.exs
defmodule ExampleWeb.Plugs.RedirectsTest do
  use ExampleWeb.ConnCase, async: true

  import Example.RedirectsFixtures

  alias ExampleWeb.Plugs.Redirects

  describe "call when there is a redirect" do
    test "redirects away to admin dashboard path" do
      redirect = redirect_fixture()

      conn =
        Plug.Test.conn(:get, redirect.path, %{})
        |> Redirects.call([])

      assert conn.halted
      assert redirected_to(conn, 301) == redirect.redirect_to

  describe "call when there is no redirect" do
    test "doesnt redirect" do
      conn =
        Plug.Test.conn(:get, "/some-path", %{})
        |> Redirects.call([])

      refute conn.halted

And I can see that the test passes. That is all and well, but I would like to test the plug used in the application as well.

STEP 3 - Use the plug in the app

To make sure that it works, I need to setup the routes that I want to redirect from and to. Also, I need to mount my plug somewhere.

I have defined a new pipeline called :maybe_redirect that I then use in one of the scope-blocks. And in the scope-block I define the old and new routes

# lib/example_web/router.ex
pipeline :maybe_redirect do
  plug ExampleWeb.Plugs.Redirects

scope "/", ExampleWeb do
  pipe_through [:browser, :maybe_redirect]

  live "/old-url", PageLive, :index
  live "/new-url", PageLive, :index

Since this is for testing purpose only, I point them to the same LiveView page.

And the test for this looks like:

# test/example_web/live/page_live_test.exs
test "redirects correctly when the path should be redirected", %{conn: conn} do
  redirect_fixture(%{path: "/old-url", redirect_to: "/new-url"})

  {:error, {:redirect, %{flash: %{}, to: "/new-url"}}} = live(conn, "/old-url")

  {:error, {:redirect, %{flash: %{}, to: "/new-url?some=param"}}} = live(conn, "/old-url?some=param")

The tests passes and I can now see that the redirect logic works and even in the case where I send params.


Depending on your pageload, there might be an idea to cache the queries. I have a tutorial about implementing caching with Cachex here: https://fullstackphoenix.com/quick_tips/liveview-caching