Server-Rendered Page-Context dataLayer, Declared in the Head and Pushed at Browser Idle
theme.liquid declares window.dataLayer ahead of the platform's head content, then pushes a context object assembled in Liquid — locale, shop country, page type and, for signed-in customers, subscriber, loyalty and login state — when the browser is idle, so every tag in the container reads the same server-side facts.
A tag manager container only knows what the page tells it, and Shopify's storefront JavaScript has no authenticated customer object to tell it from. On Three Ships, theme.liquid declares window.dataLayer ahead of the platform's head content, then pushes a context object built in Liquid — language, country, section, and subscriber, loyalty and login state for signed-in customers — at browser idle. This describes the seeding, not the reporting.
The Problem
Shopify's theme layer has no server-side tag manager. The container script runs in the browser, and everything it knows about the page it has to be told by the page. The facts marketing wants on every hit — which language the visitor is browsing in, which page type they're on, whether they're signed in, whether they subscribe, what loyalty tier they hold — exist in Liquid at render time, in shop.locale, request.page_type and the customer object. None of them exists in storefront JavaScript. There is no authenticated customer object in the browser, and re-deriving page type from a URL is guesswork the platform has already done correctly on the server.
Three Ships runs GA4, its ad platforms and remarketing through one Google Tag Manager container, and wanted those facts attached to every hit without a second derivation on the client. So the context had to be emitted from Liquid, in the layout, and land in the array the container reads.
The Constraint
The values only exist in the theme layout at render time, so the push has to be authored there, in Liquid, and serialized into the page. Customer-specific keys have to be conditional on customer — a guest has no tags to read — and a guest must not receive placeholder values that a trigger could mistake for a real state.
The container is shared. Shopify's own scripts and any app tag can push into dataLayer as well, and some of them are emitted through the platform's head content, so the array has to exist before that point or an early push throws. And the keys themselves are a moving target: what marketing wants attached to a hit is reconfigured in GTM, so the theme's job is to state the facts it holds rather than to anticipate which tag will read them.
What We Built
In <head>, ahead of the platform's head content, theme.liquid emits window.dataLayer = window.dataLayer || []. That one line is the contract. dataLayer is a plain array, so from that point on anything in the document — Shopify's own scripts, an app tag, the theme — has somewhere to push without first testing whether the array exists.
The context push runs on DOMContentLoaded, wrapped in a runIdle() helper that uses requestIdleCallback where the browser has it and setTimeout(callback, 1) where it doesn't. It pushes twice. First { ecommerce: null }, which resets the ecommerce object so nothing from a previous virtual page view leaks into this one. Then the context object built in Liquid: language from shop.locale, country from shop.address.country_code, section from request.page_type, and — only inside {% if customer %} — subscriber, loyalty_status and login_status: 'logged-in'. It is deliberately not an event push: it sets page-scoped variables that container triggers and tags read, rather than firing anything.
One more branch: {% if request.page_type == '404' %} pushes a page_error event carrying error_code: '404' and an error_referrer key set to window.location.href — the address that 404'd — after the context push, so soft-404 traffic is attributable to a language and a page type. It comes from Liquid rather than from a client-side URL check because request.page_type is authoritative — the platform knows it served a 404; a script guessing from the path doesn't.
Why This Way
The whole design rests on where the facts come from. shop.locale, request.page_type and the customer object are things the server knew when it rendered the page; a script re-deriving them in the browser is reading a URL, a cookie or a DOM node and calling the result the same thing. So the theme states them, in the layout, once, in the form the container already reads — a dataLayer push — and states nothing it would have to guess at.
The push is wrapped in runIdle(), which uses requestIdleCallback where the browser has it and a one-millisecond setTimeout where it doesn't. Pushing { ecommerce: null } first is defensive plumbing from GTM's own guidance, so an ecommerce object from an earlier push can't bleed into a later hit.
The cost sits on the theme side. Every context key is Liquid, so a new field marketing wants — a customer's market, say, or an order count — is a theme deploy rather than a container edit. And the values are what Liquid knew at render time; a state change that happens without a page load isn't reflected until the next one. We took both because the alternative was deriving customer state in the browser from something less authoritative than the customer object.
Implementation Notes
window.dataLayer = window.dataLayer || []is emitted in<head>ahead of the platform's head content, so Shopify's own scripts and any app tag can push before theme code runs.runIdle()wrapsrequestIdleCallbackand falls back tosetTimeout(callback, 1)where the API isn't available.- The context push carries no
eventkey on purpose; it sets page-scoped variables that GTM triggers read, and nothing fires on the push itself. { ecommerce: null }is pushed before the context object, resetting the ecommerce namespace on every page.- The customer keys —
subscriber,loyalty_status,login_status: 'logged-in'— are wrapped in{% if customer %}, so a guest's push simply lacks them. - The 404 branch keys off
request.page_type, not the URL; itserror_referrerkey carrieswindow.location.href, and it pushes after the context so the error inherits language and page type.
Edge Cases
- Guest visitors: the customer keys are absent rather than set to empty strings, so the matching GTM variables stay
undefinedinstead of reading as a false "none". - No
requestIdleCallback: the one-millisecondsetTimeoutfallback runs the same push, so browsers without the API still seed the same keys. - 404 pages: the context push runs first and the
page_errorevent second, so the error carries language and page type rather than arriving bare. - Repeated virtual page views inside one document: the
{ ecommerce: null }reset stops an ecommerce object from a previous push carrying over.
Platform Primitives Used
- dataLayer — the plain-array convention the whole design leans on: declared ahead of the platform's head content, pushed to at idle, and read by the container as page-scoped variables rather than as an event.
- Customer accounts — the Liquid
customerobject is the only authenticated source of subscriber, loyalty and login state, and it exists only at render time. - Theme locales and internationalization —
shop.localesupplies thelanguagekey; the storefront's active locale is a render-time fact, not a browser one.
Integrations in Play
- Google Tag Manager — the container that reads the array. This page covers what the theme writes into
dataLayer; what any tag inside the container does with those values is configured in GTM and isn't described here.
Where It Runs
On every page of the storefront: the declaration, the idle push and the 404 branch all live in the layout, so every template — home, collection, product, cart, pages, blog, account and the 404 page — seeds the same keys the same way. There is no template-specific variant of the push; template identity is carried inside it as section.
What This Demonstrates
- Third-party script governance — the primary capability: the theme decides, in one file, what a shared third-party container is told about the page, and states it from the server rather than leaving it to be re-derived.
- Analytics and dataLayer instrumentation — a server-rendered
dataLayerthat classifies page type, locale and customer traits. Its companion on the same storefront, the GA4 ecommerce event wiring across a vendor cart, covers the event side.
How We Know
The layout file, of which roughly 120 lines are this build, read from the live theme, plus thirteen client records: task-register entries for the data layer and tag manager thread, a bug report and two roadmap entries. Confidence is strong on what the code does. We make no claim here about the completeness or accuracy of what the container collects. What's described is the page-context seeding as the code expresses it, and nothing downstream of it.
Related Builds
- GA4 ecommerce dataLayer instrumentation across a Rebuy cart — the same storefront, the same array, the event side: cart adds recovered at the network layer and pushed with the item taxonomy. This page seeds the context those events inherit.
- Layout shell rewritten as a load-order contract — Nudestix's layout, where the same instinct — decide in
theme.liquidwhat runs when — is applied to stylesheets, fonts and a session recorder. - Font delivery and a template-conditioned image preload in a licensed theme's layout — the other layout-level work on this storefront, sitting a few dozen lines away in the same file.
The Buy-vs-Build Question
A server-side tagging product buys a managed pipeline and moves the container off the browser; it doesn't remove the need for something on the page to say what the page is. Seeding that from Liquid cost a theme deploy per new key and bought a source of customer truth the browser can't fake. The decision is argued at server-side tracking and the data layer: buy or build?.
Provenance & Evidence
- Client: Three Ships — threeshipsbeauty.ca
- Surface: Global — the layout wraps every template
- Templates served: all of them, including the 404 template
- Complexity: Medium
- Scale: roughly 120 lines inside the layout file
- Attribution: Deploi-authored. The declaration, the idle push, the Liquid-built context object and the 404 event are ours. They live in the layout of Palo Alto 5.8.0 by Presidio Creative, a paid premium theme the brand licenses; the theme around them is the vendor's.
- Status: Live, verified 2026-09-07
- Evidence: one layout file read from the theme, plus thirteen client records — task register, bug report and roadmap
- Confidence: Strong on the seeding as coded. No claim on the completeness or accuracy of downstream collection.
- Primary capability: Third-party script governance
Ready to Give Your Tags One Source of Truth?
If your container is guessing page type from the URL and customer state from a cookie, it's guessing at things your theme already knows. Contact us today and we'll map what your storefront can tell your tags from the server side — and what it can't.