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

Phoenix Bolerplate

Generate a Phoenix Boilerplate and save hours on your next project.

Try now

SAAS Starter Kit

Get started and save time and resources by using the SAAS Starter Kit built with Phoenix and LiveView.

Subscribe for $39/mo to geat ahead!

Learn More