Skip to main content

Load first-paint resources before content_for_header

Place the stylesheets, font preloads, and scripts that render anything the user sees before they scroll above {{ content_for_header }} in your layout. Shopify can send everything above that tag to the browser as soon as it's rendered. The rest of the page follows when your sections finish. What sits above the tag reaches the browser first.


Shopify can stream storefront HTML in two parts, and on most pages it does. The first part is your layout from <!doctype html> to {{ content_for_header }}. The browser receives it while Shopify is still rendering the sections in your template, and it starts fetching every stylesheet, font, and script it finds there right away. The second part, starting with the output of content_for_header and continuing through </body>, arrives when rendering completes.

If your critical stylesheet link sits below {{ content_for_header }}, then on a streamed page the browser can't request it until your sections have finished rendering. On a product or collection page with a heavy theme that can be hundreds of milliseconds of Liquid work. Move the link above the tag and the download overlaps with that work instead of waiting for it, so First Contentful Paint (FCP) and Largest Contentful Paint (LCP) come earlier.

Shopify streams eligible pages automatically, and it's expanding the set of pages that qualify. A layout that follows this practice loses nothing when a page isn't streamed, because the browser still receives the resources in the order it needs them. Streaming doesn't change how long your Liquid takes to render, so the Liquid best practices still apply. It changes when the browser can start its own work.

Horizon renders its stylesheets, font preloads, import map, module preloads, and CSS variables above {{ content_for_header }}, so all of them reach the browser in the first part of a streamed response. Dawn puts {{ content_for_header }} early in the head and loads base.css, its font_face declarations, and its component stylesheets below it, so those wait for the sections.


Anchor to Move first-paint resources upMove first-paint resources up

Everything the browser needs to paint the initial viewport belongs above {{ content_for_header }}:

  • <meta charset>, <meta name="viewport">, and <title>.
  • Stylesheet links for your base styles and for components visible in the initial viewport, loaded through stylesheet_tag.
  • @font-face declarations through font_face, and, if you preload a font for text the user sees before scrolling, that preload_tag too.
  • Inline {% style %} blocks that define CSS custom properties from theme settings.
  • Your import map and any modulepreload links, followed by the module scripts that use them.
  • async scripts that fetch above-the-fold content from an external API, so the request goes out during the section render. See Send data requests for critical content before the DOM is ready.

Keep base styles in a CSS file in assets/ rather than in {% stylesheet %} tags. Shopify compiles {% stylesheet %} output from sections, blocks, and snippets into one file and links it from content_for_header. On themes such as Horizon that file is subset to the sections and blocks on the current page, which Shopify only knows once they've rendered, so the link always arrives with the second part of the response. That's the right place for section-scoped styles, and the wrong place for the styles your first paint depends on.

Anchor to Keep ,[object Object], as it isKeep content_for_header as it is

Streaming depends on Shopify finding {{ content_for_header }} as a plain output tag inside <head>. Don't apply filters to it, wrap it in {% if %}, {% capture %}, or {% liquid %}, assign it to a variable, or move it into a snippet. The ContentForHeaderModification theme check flags some of these.

The same applies to layouts that apps provide. A landing page builder or a similar app that ships its own layout and captures or rewrites content_for_header turns streaming off for every page that uses that layout. Check the layouts under layout/ that you didn't write.

For the full list of what makes a page eligible, including template type, see Streamed HTML responses on the platform page. Preview themes, the preview bar, and the theme editor aren't streamed. Test on your live theme.

Anchor to Check what depends on ,[object Object]Check what depends on content_for_header

content_for_header defines the window.Shopify object and outputs stylesheets and deferred scripts of its own, so moving your code above it changes what's available when that code runs. Check three things before you move anything:

  1. Scripts that read Shopify.* while the page is parsing. An inline <script>, or an external <script src> without defer, async, or type="module" (the form script_tag produces), runs as soon as the parser reaches it. Above {{ content_for_header }}, the Shopify object doesn't exist yet, so Shopify.shop, Shopify.locale, Shopify.currency, Shopify.routes.root, and the rest throw a ReferenceError. Leave such scripts where they are. They gain little from moving up, and making them wait for Shopify adds complexity for no benefit. If the value is available in Liquid, read it there instead: request.design_mode, routes.root_url, request.locale, and cart.currency cover the common cases.

    layout/theme.liquid

    {%- comment -%} Breaks above the tag: Shopify isn't defined yet {%- endcomment -%}
    <script>
    window.themeRoot = Shopify.routes.root;
    </script>

    {%- comment -%} Works anywhere: the value comes from Liquid {%- endcomment -%}
    <script>
    window.themeRoot = {{ routes.root_url | json }};
    </script>

    {{ content_for_header }}
  2. Stylesheets that compete with Shopify's. content_for_header links the stylesheet compiled from {% stylesheet %} tags and, on pages that render dynamic checkout buttons, the styles for those buttons. When two rules have equal specificity, the later stylesheet wins. A theme stylesheet moved from below the tag to above it now loses those conflicts. After you move a stylesheet, check components styled through {% stylesheet %} and the .shopify-payment-button area, and raise specificity where a rule stopped applying.

    layout/theme.liquid

    {%- comment -%}
    Before: base.css comes after content_for_header, so its rules win
    ties against the compiled stylesheet file.
    {%- endcomment -%}
    {{ content_for_header }}
    {{ 'base.css' | asset_url | stylesheet_tag }}

    {%- comment -%}
    After: base.css comes first, so the compiled file wins the same ties.
    A rule such as `.card { padding: 0 }` in base.css that used to override
    `.card { padding: 1rem }` from a section's stylesheet tag no longer does.
    {%- endcomment -%}
    {{ 'base.css' | asset_url | stylesheet_tag }}
    {{ content_for_header }}
  3. defer scripts that depend on {% javascript %} code. content_for_header also outputs the deferred scripts compiled from {% javascript %} tags. Deferred scripts run in document order, so a theme script moved above the tag now runs before them. This only matters if your theme script reads something a {% javascript %} block defines. The reverse dependency, section code that reads globals from your theme script, becomes safer.

    layout/theme.liquid

    {%- comment -%}
    theme.js runs after the compiled javascript bundle when it's below the tag,
    and before it when it's above. Only code in theme.js that reads
    something the bundle defines notices the difference.
    {%- endcomment -%}
    <script src="{{ 'theme.js' | asset_url }}" defer></script>
    {{ content_for_header }}

Import maps need no extra care. Keep yours ahead of the module scripts that use it, as you would anyway. Its position relative to {{ content_for_header }} doesn't matter.

Apps need nothing from you. App embed blocks, theme app extension assets, and scripts that apps inject land after content_for_header regardless of how you arrange your layout.

Anchor to Keep the layout head cheapKeep the layout head cheap

Shopify renders everything above {{ content_for_header }} before it can send the first part of the response. Expensive Liquid in the layout head, such as a meta-tags snippet that loops over collections or product.variants, delays the whole benefit. Keep catalog loops in sections, where they belong anyway, and keep the head to metadata, resource links, and settings-driven CSS.


A layout head arranged for streaming. This is the structure Horizon uses, with the snippets inlined:

layout/theme.liquid

<!doctype html>
<html lang="{{ request.locale.iso_code }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page_title }}</title>
<link rel="canonical" href="{{ canonical_url }}">

{%- unless settings.type_body_font.system? -%}
{{ settings.type_body_font | font_url | preload_tag: as: 'font', type: 'font/woff2' }}
{%- endunless -%}

{{ 'base.css' | asset_url | stylesheet_tag: preload: true }}

{% style %}
{{ settings.type_body_font | font_face: font_display: 'swap' }}
:root {
--color-background: {{ settings.color_background }};
--font-body-family: {{ settings.type_body_font.family }}, {{ settings.type_body_font.fallback_families }};
}
{% endstyle %}

<script type="importmap">
{
"imports": {
"@theme/utilities": "{{ 'utilities.js' | asset_url }}"
}
}
</script>
<link rel="modulepreload" href="{{ 'utilities.js' | asset_url }}">
<script src="{{ 'global.js' | asset_url }}" type="module"></script>

{% if request.design_mode %}
<script src="{{ 'theme-editor.js' | asset_url }}" defer></script>
{% endif %}

{{ content_for_header }}
</head>
<body>
{% sections 'header-group' %}
<main id="MainContent">
{{ content_for_layout }}
</main>
{% sections 'footer-group' %}
</body>
</html>

An inline script that shouldn't move above the tag. Dawn's head ends with this, well below {{ content_for_header }}:

layout/theme.liquid

{{ content_for_header }}

{%- comment -%} ... stylesheets, fonts, and settings-driven styles ... {%- endcomment -%}

<script>
if (Shopify.designMode) {
document.documentElement.classList.add('shopify-design-mode');
}
</script>

Moved above {{ content_for_header }}, it throws ReferenceError: Shopify is not defined on the live storefront. Leave it in place, or read the value in Liquid, which removes the script altogether:

layout/theme.liquid

<html
lang="{{ request.locale.iso_code }}"
{% if request.design_mode %}class="shopify-design-mode"{% endif %}
>

  • Open the Network panel in Chrome DevTools, reload a product or collection page on your live theme, and select the document request. Preview themes and the theme editor aren't streamed, so the split doesn't show there. On a streamed page, Waiting for server response in the Timing tab ends when the first part of the response arrives and Content download covers the rest, so your stylesheet and font requests should start during Content download rather than after it. If the page wasn't streamed, they start right after the document arrives either way, and the comparison in the next step is the one to rely on.
  • Compare two versions of the layout: one with your stylesheet link below {{ content_for_header }} and one with it above. In the waterfall, the stylesheet request starts earlier in the second version. Run each several times and compare medians, because network noise is larger than a single run's difference.
  • Run a Lighthouse audit before and after. FCP and LCP should improve. The render-blocking resources audit still lists your stylesheets, which is expected. The goal is to start them earlier, not to remove them.
  • Load the page with the theme editor and with a normal storefront visit, and check the console for ReferenceError: Shopify is not defined. The editor defines the Shopify object at the top of the head, so a script that only breaks outside the editor is easy to miss.
  • Run Theme Check to confirm ContentForHeaderModification passes.


Was this page helpful?