Create a sitemap in Phoenix 1.7+

A guide on maintaining your own sitemap.

originally published Wed Sep 13 2023 00:00:00 GMT+0000 (Coordinated Universal Time)

Recently I’ve been working on some SEO stuff for a site of mine (an RSS reader app, catnip.vip ), and wanted to set up a sitemap.

I went looking for ideas online, and found a lot of people using packages that would construct their sitemaps dynamically (usually based on their Ecto models or something).

The content I was interested in indexing was a small, set series of pages, so I didn’t really have any need of that. I figured it’d be simple enough to set up a sitemap by hand.

Phoenix 1.7 (and the lack of views)

Phoenix 1.7 includes some radical changes to how the view layer functions (by getting rid of it). Here's how you may go about building a sitemap in Phoenix 1.7.

Although we’ll be maintaining this list by hand instead of generating it dynamically, we’ll also make use of an amazing feature of Phoenix 1.7: compile-time verified routes.

Instead of a controller and a view, we’re going to need a controller and an HTML module. Make them:

defmodule ExampleWeb.SitemapController do
  use ExampleWeb, :controller

  def index(conn, _) do
    # todo
  end
end

sitemap_controller.ex

defmodule ExampleWeb.SitemapHTML do
  use ExampleWeb, :html

  embed_templates "sitemap_html/*"
end

sitemap_html.ex

Then let’s put the shell of our template in:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <%= show_pages() %>
</urlset>

sitemap_html/index.xml.eex

We’ll define a function called show_pages in SitemapHTML to loop through a list of pages, and create some XML:

# list pages with sigil_p for compile-time checking
def pages do
  [
    # application
    ~p"/",
    ~p"/posts",
    ~p"/feeds",
    # general pages
    ~p"/privacy",
    ~p"/terms",
    # billing
    ~p"/pricing",
  ]
end

def show_pages do
  for path <- pages() do
    route = ExampleWeb.Endpoint.url() <> path

    """
    <url>
      <loc>#{route}</loc>
      <priority>0.5</priority>
      <changefreq>weekly</changefreq>
    </url>
    """
  end
end

sitemap_html.ex

This is where the verified routes come in. If we were to make a mistake in this list, we’d know right away, which makes maintaining this list yourself a lot less daunting.

Next, let’s add in a compile-time macro to set the last modified time:

defmacro today do
  quote do
    Date.utc_today()
  end
end

def show_pages do
  for path <- pages() do
    route = ExampleWeb.Endpoint.url() <> path

    """
    <url>
      <loc>#{route}</loc>
      <lastmod>#{today()}</lastmod>
      <priority>0.5</priority>
      <changefreq>weekly</changefreq>
    </url>
    """
  end
end

sitemap_html.ex

Now, we may fill in the index route in the controller:

defmodule ExampleWeb.SitemapController do
  use ExampleWeb, :controller

  def index(conn, _) do
    xml = ExampleWeb.SitemapHTML.index(%{})

    conn
    |> put_resp_content_type("text/xml")
    |> text(xml)
  end
end

sitemap_controller.ex

Finally, add this controller to your routes:

scope "/", ExampleWeb do
  pipe_through(:browser)

  get("/sitemap", SitemapController, :index)
end

router.ex

And that’s pretty much it!

You can check out an example of the output on one of my sites.