---
title: 'Render essential content in Liquid and HTML, not JavaScript'
description: >-
  Render initial content with Liquid and HTML instead of client-side frameworks
  to eliminate the multi-step JavaScript waterfall that delays FCP, LCP, and
  defeats the browser's preload scanner. If frameworks are required, then use
  server-side rendering (Hydrogen), not client-side frameworks on Liquid
  storefronts.
source_url:
  html: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/render-essential-content-server-side
  md: >-
    https://shopify.dev/docs/storefronts/themes/best-practices/performance/render-essential-content-server-side.md
api_name: liquid
---

# Render essential content in Liquid and HTML, not Java​Script

Render initial content with Liquid and HTML instead of client-side frameworks. Use server-side rendering (Hydrogen) if frameworks are required, not client-side frameworks on Liquid storefronts.

***

## Why

When a browser receives server-rendered HTML, it can paint content and discover resources immediately. The preload scanner reads through HTML as it arrives and starts fetching images, stylesheets, and fonts before the page finishes loading. This is the fastest path to visible content.

Client-side JavaScript frameworks bypass this entirely. Instead of receiving ready-to-display HTML, the browser receives an empty shell and must complete a sequential chain before the user sees anything:

1. Download the HTML document (which contains little or no visible content).
2. Parse the HTML and discover the framework's JavaScript bundle.
3. Download, parse, and execute the JavaScript bundle.
4. The framework makes data-fetching requests (to the Storefront API or other endpoints).
5. After data returns, the framework renders content into the DOM.
6. Only now can the browser discover images and other resources that the rendered content references.

Each step in this chain depends on the previous step completing. The result is a long sequential waterfall where the browser sits idle, waiting, while the user stares at a blank or skeleton page.

This waterfall directly harms three Core Web Vitals metrics:

* **[FCP](https://web.dev/fcp/) (First Contentful Paint):** Nothing renders until the framework finishes executing and inserting content. With server-rendered HTML, the browser paints content as soon as it parses the HTML.
* **[LCP](https://web.dev/lcp/) (Largest Contentful Paint):** The LCP image can't be discovered until after JavaScript renders the `img` element into the DOM. The preload scanner never sees it because it wasn't in the original HTML.
* **[INP](https://web.dev/inp/) (Interaction to Next Paint):** Large JavaScript bundles consume main-thread time during parsing and execution. While the main thread is busy with framework code, user interactions (taps, clicks, scrolls) are delayed or unresponsive.

***

## How

### Use Liquid for initial page content

Render the initial view of every page with Liquid and HTML. When the server responds with complete HTML, the browser displays content immediately and the preload scanner discovers all referenced resources in the markup.

Before: A client-side framework fetches product data and renders it with JavaScript.

```html
<!-- The server sends an empty container -->
<div id="product-root"></div>
<script src="{{ 'product-app.bundle.js' | asset_url }}" defer></script>
<script>
  // After the bundle loads, parses, and executes:
  // 1. Fetch product data from the Storefront API
  // 2. Build the DOM from the response
  // 3. Insert it into #product-root
  // The browser can't display anything or discover the product image until this completes.
</script>
```

After: Liquid renders the product directly into HTML. The browser displays the product image and text as soon as it parses the response.

```liquid
<div class="product">
  <div class="product__media">
    {{ product.featured_image
      | image_url: width: 1200
      | image_tag:
          loading: 'eager',
          fetchpriority: 'high',
          widths: '400, 600, 800, 1200',
          sizes: '(min-width: 1000px) 600px, calc(100vw - 2rem)'
    }}
  </div>
  <div class="product__info">
    <h1>{{ product.title }}</h1>
    <p class="product__price">{{ product.selected_or_first_available_variant.price | money }}</p>
    <div class="product__description">{{ product.description }}</div>
  </div>
</div>
```

In the Liquid version, the product image URL is present in the HTML from the start. The preload scanner discovers it immediately and begins downloading the image in parallel with other page resources. No JavaScript needs to execute first.

### Layer Java​Script progressively

Start with server-rendered content, then add interactivity on top. The initial HTML works without JavaScript. JavaScript enhances the experience when it loads.

This approach is called progressive enhancement. The server-rendered HTML provides the baseline experience, and JavaScript adds interactive features, such as variant switching, quantity selectors, and add-to-cart behavior.

```liquid
{%- comment -%}
  Server-rendered product form. The form, price, image, and buy button
  are all present in the HTML, so the content is visible before any
  JavaScript runs. JavaScript enhances option switching on top of it.
{%- endcomment -%}


<div class="product" data-section-id="{{ section.id }}">
  <div class="product__media">
    {{ product.selected_or_first_available_variant.image
      | default: product.featured_image
      | image_url: width: 1200
      | image_tag:
          loading: 'eager',
          fetchpriority: 'high',
          widths: '400, 600, 800, 1200',
          sizes: '(min-width: 1000px) 600px, calc(100vw - 2rem)',
          id: 'product-image'
    }}
  </div>


  <div class="product__info">
    <h1>{{ product.title }}</h1>
    <p class="product__price" id="product-price">
      {{ product.selected_or_first_available_variant.price | money }}
    </p>


    {%- comment -%}
      Render the picker from `product.options_with_values`. Looping
      `product.variants` truncates at 250 variants and costs render time on
      every request. See "Avoid over-fetching product variants".
    {%- endcomment -%}
    <div class="option-value-selectors" data-section-id="{{ section.id }}">
      {%- for option in product.options_with_values -%}
        <fieldset>
          <legend>{{ option.name }}</legend>


          {%- for option_value in option.values -%}
            <input
              id="{{ section.id }}-{{ option.position }}-{{ forloop.index0 }}"
              type="radio"
              name="{{ section.id }}-{{ option.name }}-{{ option.position }}"
              value="{{ option_value | escape }}"
              {% if option_value.selected %}checked{% endif %}
              {% unless option_value.available %}disabled{% endunless %}
              data-option-value-id="{{ option_value.id }}"
            />
            <label for="{{ section.id }}-{{ option.position }}-{{ forloop.index0 }}">
              {{ option_value }}
            </label>
          {%- endfor -%}
        </fieldset>
      {%- endfor -%}
    </div>


    {% form 'product', product %}
      {%- comment -%}
        The selected variant is resolved on the server, so the form posts a
        single ID instead of listing every variant.
      {%- endcomment -%}
      <input
        type="hidden"
        name="id"
        id="variant-id"
        value="{{ product.selected_or_first_available_variant.id }}"
      >


      <button type="submit" class="product__add-to-cart">
        Add to cart
      </button>
    {% endform %}
  </div>
</div>
```

The JavaScript that enhances this form is small and targeted. It asks the server to re-render the section for the newly selected option values, then swaps in the returned HTML. It doesn't render any of the initial content, and it doesn't need variant data in the page:

```javascript
// product-form.js - enhances the server-rendered form
const productElement = document.querySelector('.product');


// Listen on the container that survives the swap, so the handler still works
// after the picker markup is replaced.
productElement.addEventListener('change', async (event) => {
  // Scope to the picker that fired the event: a document-wide lookup returns
  // the wrong section when a page renders more than one product.
  const selectors = event.target.closest('.option-value-selectors');
  if (!selectors) return;


  const sectionId = selectors.dataset.sectionId;
  const optionValues = Array.from(
    selectors.querySelectorAll('input[type="radio"]:checked'),
  )
    .map(({dataset}) => dataset.optionValueId)
    .join(',');


  const response = await fetch(
    `${window.location.pathname}?section_id=${sectionId}&option_values=${optionValues}`,
  );
  const html = new DOMParser().parseFromString(
    await response.text(),
    'text/html',
  );


  // The server returns the updated price, image, option availability, and
  // variant ID, so the browser doesn't need any variant data.
  const selectedId = event.target.id;
  productElement.innerHTML = html.querySelector('.product').innerHTML;


  // Replacing the markup discards focus, so restore it on the option the
  // buyer selected.
  document.getElementById(selectedId)?.focus();
});
```

### Use the Section Rendering API for dynamic updates

When content needs to change after the initial page load, use the [Section Rendering API](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-section-rendering-api) to fetch server-rendered HTML fragments instead of building DOM with JavaScript. The server renders the updated section with Liquid and returns the complete HTML. Your JavaScript replaces the existing section content with the server's response.

This keeps your rendering logic in Liquid where it belongs. The server handles the data and the templates. JavaScript handles fetching and swapping the HTML.

```javascript
// Fetch a server-rendered section with a different variant selected
async function updateSection(sectionId, variantId) {
  const url = `${window.location.pathname}?variant=${variantId}&sections=${sectionId}`;
  const response = await fetch(url);
  const data = await response.json();
  const html = data[sectionId];


  // Sections that fail to render come back as `null` inside a `200` response.
  if (!html) return;


  // The response is the full `shopify-section` wrapper, so replace the wrapper.
  // Writing it into an inner element nests a second wrapper and duplicates its ID.
  document.getElementById(`shopify-section-${sectionId}`).outerHTML = html;
}
```

This approach provides several advantages over client-side rendering:

* Rendering logic stays in a single place (Liquid templates).
* The server handles data access, so the browser doesn't need API keys or complex data-fetching code.
* The HTML response is ready to display. No parsing, no template compilation, no virtual DOM diffing.
* The JavaScript you ship is minimal: just `fetch` and DOM replacement.

For more detail, see [Use the Section Rendering API for dynamic updates](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-section-rendering-api).

***

## Examples

### Example 1: Product page rendered with Liquid instead of Java​Script

Before: JavaScript fetches product data and renders the entire product display.

```html
<div id="product-app"></div>
<script src="{{ 'product-framework.js' | asset_url }}" defer></script>
<script src="{{ 'product-app.js' | asset_url }}" defer></script>
<!--
  Load timeline:
  1. Browser receives empty <div>
  2. Downloads ~80-200 KB of JavaScript (framework + app)
  3. Parses and executes JavaScript
  4. JavaScript fetches product data from the Storefront API
  5. JavaScript renders the product into #product-app
  6. Browser discovers the product image for the first time
  7. Browser downloads the product image
-->
```

After: Liquid renders the product. The image and content are in the initial HTML. The option picker uses [`product.options_with_values`](https://shopify.dev/docs/api/liquid/objects/product#product-options_with_values), so the page doesn't load every variant, and JavaScript adds option switching through the Section Rendering API.

```liquid
<section class="product" data-section-id="{{ section.id }}">
  <div class="product__media">
    {{ product.featured_image
      | image_url: width: 1200
      | image_tag:
          loading: 'eager',
          fetchpriority: 'high',
          widths: '400, 600, 800, 1200',
          sizes: '(min-width: 1000px) 600px, calc(100vw - 2rem)'
    }}
  </div>


  <div class="product__details">
    <h1>{{ product.title }}</h1>
    <p class="product__price">
      {{ product.selected_or_first_available_variant.price | money }}
    </p>
    <div class="product__description">{{ product.description }}</div>


    <div class="option-value-selectors" data-section-id="{{ section.id }}">
      {%- for option in product.options_with_values -%}
        <fieldset>
          <legend>{{ option.name }}</legend>
          {%- for option_value in option.values -%}
            <input
              id="{{ section.id }}-{{ option.position }}-{{ forloop.index0 }}"
              type="radio"
              name="{{ section.id }}-{{ option.name }}-{{ option.position }}"
              value="{{ option_value | escape }}"
              {% if option_value.selected %}checked{% endif %}
              {% unless option_value.available %}disabled{% endunless %}
              data-option-value-id="{{ option_value.id }}"
            />
            <label for="{{ section.id }}-{{ option.position }}-{{ forloop.index0 }}">
              {{ option_value }}
            </label>
          {%- endfor -%}
        </fieldset>
      {%- endfor -%}
    </div>


    {% form 'product', product %}
      <input
        type="hidden"
        name="id"
        value="{{ product.selected_or_first_available_variant.id }}"
      >
      <button type="submit">Add to cart</button>
    {% endform %}
  </div>
</section>


<!--
  Load timeline:
  1. Browser receives complete HTML with product content and image URL
  2. Preload scanner discovers the product image immediately
  3. Browser starts downloading the image while still parsing the rest of the page
  4. Content is painted at FCP
  5. Small enhancement script loads and adds variant switching
-->
```

### Example 2: Collection grid

Before: JavaScript fetches the collection and renders a product grid.

```html
<div id="collection-grid"></div>
<script src="{{ 'collection-app.js' | asset_url }}" defer></script>
<!--
  JavaScript fetches products, builds card HTML for each one,
  and inserts the grid. No product images are discoverable
  until the script finishes executing.
-->
```

After: Liquid renders the grid. Filtering and sorting use the Section Rendering API to fetch updated server-rendered HTML.

```liquid
{% paginate collection.products by 24 %}
<section class="collection" data-section-id="{{ section.id }}">
  <form class="collection__filters">
    {% for filter in collection.filters %}
      <details class="filter-group">
        <summary>{{ filter.label }}</summary>
        <ul>
          {% for value in filter.values %}
            <li>
              <label>
                <input
                  type="checkbox"
                  name="{{ value.param_name }}"
                  value="{{ value.value }}"
                  {% if value.active %}checked{% endif %}
                  data-filter-input
                >
                {{ value.label }} ({{ value.count }})
              </label>
            </li>
          {% endfor %}
        </ul>
      </details>
    {% endfor %}
  </form>


  <div class="collection__grid" id="collection-products">
    {% for product in collection.products %}
      {%- liquid
        if forloop.index <= 4
          assign image_loading = 'eager'
        else
          assign image_loading = 'lazy'
        endif
      -%}
      <div class="product-card">
        {{ product.featured_image
          | image_url: width: 400
          | image_tag:
              loading: image_loading,
              widths: '200, 300, 400',
              sizes: '(min-width: 1200px) calc(25vw - 2rem), (min-width: 768px) calc(33vw - 2rem), calc(50vw - 2rem)'
        }}
        <h3>{{ product.title }}</h3>
        <p>{{ product.price | money }}</p>
      </div>
    {% endfor %}
  </div>


  <div class="collection__pagination">
    {{ paginate | default_pagination }}
  </div>
</section>
{% endpaginate %}
```

When a customer selects a filter, JavaScript fetches the updated grid from the Section Rendering API:

```javascript
// collection-filters.js
document.querySelectorAll('[data-filter-input]').forEach((input) => {
  input.addEventListener('change', async () => {
    const form = document.querySelector('.collection__filters');
    const params = new URLSearchParams(new FormData(form));
    const sectionId = document.querySelector('.collection').dataset.sectionId;


    params.set('sections', sectionId);


    const response = await fetch(`${window.location.pathname}?${params}`);
    const data = await response.json();


    const tempDiv = document.createElement('div');
    tempDiv.innerHTML = data[sectionId];
    const newGrid = tempDiv.querySelector('#collection-products');
    document.getElementById('collection-products').replaceWith(newGrid);
  });
});
```

The server handles filtering and returns a fully rendered grid. The browser doesn't need product data, template logic, or a rendering framework.

### Example 3: Diagnose whether your site relies on Java​Script for rendering

To check whether essential content depends on JavaScript:

1. Open the site in Chrome.
2. Open DevTools: Command+Option+I on macOS, or Control+Shift+I on Windows.
3. Open the Command Menu: Command+Shift+P on macOS, or Control+Shift+P on Windows.
4. Type `Disable JavaScript` and select the option.
5. Reload the page.

If the page is blank or missing its primary content, such as product images, titles, prices, or navigation, then the site relies on client-side JavaScript for rendering. Move this content to Liquid templates so that it's present in the server-rendered HTML.

Also look for framework-specific patterns that hide content until JavaScript initializes:

* **`x-cloak` (Alpine.js) and `v-cloak` (Vue)**: These attributes hide elements until the framework initializes. If any are above the fold, then users on slow connections see blank space. Check: `document.querySelectorAll('[x-cloak]')`.
* **JS-bound image attributes**: Alpine's `:loading="..."` or Vue's `v-bind:loading` set the `loading` attribute dynamically. Without the framework, these images have no `loading` attribute, so the preload scanner can't determine their priority.
* **JS-triggered visibility**: Images rendered with `opacity: 0` that use JavaScript to fade in delay LCP. The image downloads, but the browser can't record the LCP event until the element is visible.

***

## Testing

* **Disable JavaScript test**: Follow the diagnosis steps in Example 3. Every piece of content visible in the initial viewport should render without JavaScript.
* **Performance panel waterfall**: Record a performance trace in Chrome DevTools. Compare the time between navigation start and FCP. Server-rendered pages show content appearing early. JavaScript-rendered pages show a long gap where the main thread is busy executing scripts before any content appears.
* **Network panel resource discovery**: Check when the LCP image request appears in the **Network** panel waterfall. On a server-rendered page, it appears early, because the preload scanner discovers it during HTML parsing. On a JavaScript-rendered page, it appears late, after the framework bundle finishes executing.
* **Compare bundle sizes**: Check the total JavaScript payload in the **Network** panel. Server-rendered pages with progressive enhancement typically ship far less JavaScript than pages that depend on a client-side framework for rendering.

***

## References

* [Debugging common causes for slow loading in Shopify Liquid storefronts](https://performance.shopify.com/blogs/blog/debugging-common-causes-for-slow-loading-in-shopify-liquid-storefronts)
* [3 ways to find your worst JavaScript offenders for page load](https://performance.shopify.com/blogs/blog/3-ways-to-find-your-worst-javascript-offenders-for-page-load)
* [Use the Section Rendering API for dynamic updates](https://shopify.dev/docs/storefronts/themes/best-practices/performance/use-section-rendering-api)
* [Avoid over-fetching product variants](https://shopify.dev/docs/storefronts/themes/best-practices/performance/avoid-variant-overfetching)
* [Support high-variant products](https://shopify.dev/docs/storefronts/themes/product-merchandising/variants/support-high-variant-products)
* [Performance in Hydrogen](https://shopify.dev/docs/storefronts/themes/best-practices/performance/hydrogen-performance)
* [Understanding INP](https://shopify.dev/docs/storefronts/themes/best-practices/performance/understanding-inp)
* [Audit and remove third-party scripts](https://shopify.dev/docs/storefronts/themes/best-practices/performance/audit-remove-third-party-scripts)
* [Section Rendering API reference](https://shopify.dev/docs/api/ajax/section-rendering)

***
