Update head content from Phoenix layout

Phoenix supports multiple nested layouts. By default, it comes with two levels of layouts:

  • root with a basic HTML scaffold that includes elements such as head, body, etc.
  • app with app-specific components, such as nav, footer, etc.

As a Rails developer, I was looking for a similar functionality to Rails’ content_for function, which allows developers to inject content into a parent’s <head> tag from a lower-level layout.

To provide an example, here is how you would achieve this functionality in Rails:

root layout with 2 content sections:

<!-- app/views/layouts/root.html.erb --->
<html>
  <head>
  <%= yield :head %>
  </head>
  <body>
  <%= yield %>
  </body>
</html>

and app layout:

<% content_for :head do %>
  <script defer src="..."></script>
<% end %>

<nav>...</nav>
<section>
  <%= yield %>
</section>
<footer>...</footer>

Despite my best efforts, I couldn’t find a feature similar to Rails’ content_for in Phoenix. As far as I understand, Phoenix renders templates in a single, hierarchical manner - with plug.conn being passed down the template chain. This is demonstrated in the following chart:

root(conn)
    |
@inner_content -> app(conn)
                       |
                 @inner_content -> template(conn)

To work around this limitation, I created a simple functional component that is inserted into the root template. This component is responsible for rendering content based on the current layout, which is stored in the conn.private.phoenix_layout variable.”

<!--- root.html.heex --->
<head>
  ...
  <%= head_content @conn.private %>
</head>
<body>
  <%= @inner_content %>
</body>

Where head_content method is defined as follows:

defmodule YourAppWeb.Layouts do
  use YourAppWeb, :html

  embed_templates "layouts/*"

  def scripts(%{phoenix_layout: layout}) do
    case layout do
      %{"html" => {__MODULE__, html_layout}} -> scripts_for(html_layout)
      %{_: false} -> nil
    end
  end

  defp head_content_for(:app) do
    assigns = %{}
    ~H"""
    <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}></script>
    """
  end

  defp head_content_for(_), do: ""
end

With this solution, /assets/app.js file will only be included in the app layout.

PS: If you have a better solution or if you notice an error in my understanding, please don’t hesitate to let me know on Twitter.