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 --->
  <%= yield :head %>
  <%= yield %>

and app layout:

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

  <%= yield %>

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:

@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_content @conn.private %>
  <%= @inner_content %>

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

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

  defp head_content_for(_), do: ""

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.