Juneum
Elixir, Phoenix, and Javascript

Favicons in Phoenix

March 30, 2021 (Updated May 20, 2021)
trisager

Before you deploy a website in production, you will want to replace the blank favicon that you get with the default Phoenix installation. You could just create your own replacement image, convert it to the .ico format and use that, but your favicon will look better in different browsers and on different devices if it is available in the sizes and color schemes preferred by each of these.

There are many free online favicon generators, so you don't have to create all the different formats by hand. For example, you can upload an image to realfavicongenerator, and the app will walk you through the process of generating and downloading a package containing the files that you need to support various browsers and the different ways of showing pinned website pages on iOS, Android and Windows.

Depending on your selections, the package that you download will contain ~10 files. Delete the default favicon.ico file from the assets/static folder and upload the generated files in its place.

Layout Template

With the new favicon files in place, you will then need to modify the <head> section of your root layout files. If you are using realfavicongenerator, the code that it instructs you to add looks something like this:

<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">

Rather than using this as is, we'll use the static_path helper function to serve digested versions in production, so replace the href attributes in the generated code with:

href="<%= Routes.static_path(@conn, "/apple-touch-icon.png") %>"
href="<%= Routes.static_path(@conn, "/favicon-32x32.png") %>"
href="<%= Routes.static_path(@conn, "/favicon-16x16.png") %>"
href=<%= Routes.static_path(@conn, "/site.webmanifest") %>"

Then restart your Phoenix application. Webpack will copy the files from your assets/static folder to priv/static, and Phoenix will serve them from there.

But This Doesn't Seem to Work?

At this point, when you open your application in the browser or on your phone, you will discover that, except for favicon.ico, none of the files that we just created are being served by Phoenix. Instead, when you open the Network tab in the browser console, you will see a number of 404 errors. But if you check in priv/static, you will see that the favicon files are in fact present and accounted for. So what is going on?

If you look in MyAppWeb.Endpoint module on a default Phoenix site, you will see these lines:

plug Plug.Static,
  at: "/",
  from: :myapp,
  gzip: false,
  only: ~w(css fonts images js favicon.ico robots.txt)

The line only: ~w(css fonts images js favicon.ico robots.txt) means that Plug.Static will only attempt to load these static files:

  • any file inside one of the css, fonts, images, or js folders inside the static root folder
  • favicon.ico or robots.txt from the static root folder

If a file is requested that doesn't match one of these criteria, the request won't be handled by Plug.Static.

At this point you have two options: You can add each of the generated files individually to the list, e.g:

only: ~w(css fonts images js robots.txt android-chrome-192x192.png
  android-chrome-512x512.png apple-touch-icon.png browserconfig.xml
  favicon-16x16.png favicon-32x32.png favicon.ico mstile-150x150.png
  safari-pinned-tab.svg site.webmanifest)

or you can move the files to an assets/static/favicons folder and include that:

only: ~w(favicons css fonts images js robots.txt)

Using a folder saves some typing, but serving favicons from the root works better with some older browsers. If you decide to use a folder, then don't forget to add the folder name to the href attribute values in your root template.

But it Still Doesn't Work in Production?

If you decided to go for the most compatible option and serve your favicons from the root, things will break again if you use mix phx.digest when deploying your site in production. You can see why if you look in the <head> section of the rendered layout template. It will contain lines that look something like this:

<head>
  ...
  <link
    rel="apple-touch-icon"
    sizes="180x180"
    href="/apple-touch-icon-25a5303ec97527786e5ac62c491a4e53.png?vsn=d"
  />
  <link
    rel="icon"
    type="image/png"
    sizes="32x32"
    href="/favicon-32x32-c5c8f87c56a5680513d7466355af0cb3.png?vsn=d"
  />
  <link
    rel="icon"
    type="image/png"
    sizes="16x16"
    href="/favicon-16x16-9752f26068e1d904f9dde1ec240d8819.png?vsn=d"
  />
  <link
    rel="manifest"
    href="/site-b9aa277fcfc34c31db6c7a7ea3469b8c.webmanifest?vsn=d"
  />
  <link
    rel="mask-icon"
    href="/safari-pinned-tab-b3aae41d39e7e23421e5eac20f47d057.svg?vsn=d"
    color="#b91c1c"
  />
  ...
</head>

The problem is that mix phx.digest creates "digested" versions of each asset, i.e. it inserts a digital fingerprint as part of each asset's filename. The digest changes when the file is modified, so a browser will load the modified file instead of serving an old version from its cache. However, with its currrent settings, Plug.Static won't serve the digested files because we are using the :only option and the digested file names aren't listed.

One solution is to relax our requirements and use :only_matching instead:

only_matching: ~w(css fonts images js robots.txt 
  android-chrome apple-touch-icon browserconfig favicon mstile 
  safari-pinned-tab site)

:only_matching just requires the beginning of each filename to be listed (so "favicon" will match favicon.ico, favicon-32x32.png , favicon-32x32-c5c8f87c56a5680513d7466355af0cb3.png, etc)

Gzipping Assets

While not necessary for serving favicons, we can make one additional change to our config while we are here:

plug Plug.Static,
  at: "/",
  from: :myapp,
  gzip: false,
  only_matching: ~w(css fonts images js robots.txt android-chrome 
    apple-touch-icon browserconfig favicon mstile safari-pinned-tab site)

Note that :gzip is set to false, which means that static assets will be served uncompressed by Phoenix. That is fine for favicons, but if you are serving a large stylesheet or other large text files, your website will benefit from setting this to true.

mix phx.digest will create compressed versions of your static assets regardless of this setting, so there is nothing else you need to do after you enable gzip.

Static Assets and LiveView

If you are using LiveView and you have long-running single-page apps, you can ensure that clients are made aware of changes to static files, without having to wait for the next time the app is reloaded from the server.

Doing this is a two-step process. First, you must add the phx-track-static attribute to the assets you want to track, e.g:

<head>
  ...
  <link
    phx-track-static
    rel="apple-touch-icon"
    sizes="180x180"
    href="/apple-touch-icon-25a5303ec97527786e5ac62c491a4e53.png?vsn=d"
  />
  <link
    phx-track-static
    rel="icon"
    type="image/png"
    sizes="32x32"
    href="/favicon-32x32-c5c8f87c56a5680513d7466355af0cb3.png?vsn=d"
  />
  <link
    phx-track-static
    rel="icon"
    type="image/png"
    sizes="16x16"
    href="/favicon-16x16-9752f26068e1d904f9dde1ec240d8819.png?vsn=d"
  />
  <link
    phx-track-static
    rel="manifest"
    href="/site-b9aa277fcfc34c31db6c7a7ea3469b8c.webmanifest?vsn=d"
  />
  <link
    phx-track-static
    rel="mask-icon"
    href="/safari-pinned-tab-b3aae41d39e7e23421e5eac20f47d057.svg?vsn=d"
    color="#b91c1c"
  />
  ...
</head>

Next, you use the static_changed?(socket) function to let the client track changes to the assets. Example from the documentation:

def mount(params, session, socket) do
  {:ok, assign(socket, static_changed?: static_changed?(socket))}
end

In your LiveView you can check the value of @static_changed? and show a message to the user or force a reload if the version of the asset currently loaded is different from the one on the server.

← Back to articles