Liquid data boundaries and performance
Sellerlane Liquid objects are buyer-facing view models called Drops. A Drop is not a seller API response, database model, request object, or JavaScript class made available for template reflection. It exposes only the reviewed fields in the Liquid object reference.
This boundary applies to full pages, Section Rendering responses, visual-editor refreshes, Liquid assets, customer pages, and public agent-discovery templates. The same field never becomes more privileged merely because a section is fetched separately.
The sealed field contract
Every resource Drop is presented to Liquid through a sealed, non-enumerable facade. Dot and bracket notation both resolve through the same explicit public field contract:
{{ product.title }}{{ product['title'] }}
{% if product.not_a_public_field == nil %} Unknown fields are nil{% endif %}Example output:
Sandalwood CandleSandalwood Candle
Unknown fields are nilAdding a field to a seller DTO or a TypeScript class does not add it to Liquid.
Internal state, repositories, cache objects, methods, prototypes, constructors,
and serialization helpers are not callable or traversable from a theme. Public
field names use snake_case; Shopify predicate fields keep their trailing ?.
The json filter is not a reflection API. Drops with a curated public
serializer output only that projection; lookup/container Drops with no public
serializer output {}. Use the checked object reference as the schema.
Global and contextual roots
Sellerlane builds the root scope from an allowlist. The fixed storefront roots are constructed the same way for full-page and fragment renders.
Normal storefront global roots
The following table describes an ordinary buyer-facing HTML storefront. Narrow system surfaces such as agent discovery, robots, and Liquid assets receive only the subset required for that surface; they never gain additional roots.
| Root | Value |
|---|---|
shop | Curated public store information. |
settings | Values from config/settings_data.json. |
template | name, optional suffix, and optional directory. |
request | The seven safe request fields documented below. |
routes | Locale-aware built-in storefront URLs. |
cart | The current visitor cart. |
customer | Authenticated customer Drop, or nil for a guest. |
sellerlane | Namespaced Sellerlane extensions such as narrow query state and recommendations. |
all_products | Bounded product lookup by handle. |
collections | Enumerable collections plus bounded handle lookup. |
blogs | Bounded blog lookup by handle. |
articles | Bounded blog-handle/article-handle lookup. |
pages | Enumerable pages plus bounded handle lookup. |
linklists | Bounded navigation-menu lookup by handle. |
metaobjects | Bounded lookup of syntactically valid metaobject type containers; their entries are storefront-filtered. |
images | Bounded theme/store image lookup by validated filename. |
current_page | Current paginator page number. |
handle | Current primary resource handle, otherwise nil. |
Layout metadata such as canonical_url, page_title, page_description, and
page_image is also projected where the page builder provides it.
Context-only roots
| Context | Additional roots |
|---|---|
| Product template | product; collection when reached through a collection context. |
| Collection template | collection, current_tags. |
| Blog template | blog, current_tags. |
| Article template | article, blog. |
| Page template | page. |
| Search template | search. |
| Metaobject web-page template | metaobject. |
| Predictive search endpoint | predictive_search. |
| Product recommendations endpoint | Contextual recommendations result. |
| Customer account | Authenticated customer; an authorized order only on its order context. |
| Robots route | robots. |
| Gift-card route | Capability-scoped gift_card. |
{% form %} body | form. |
{% paginate %} body | paginate. |
A child object—such as a variant, cart line, address, transaction, filter value, metafield, or metaobject system record—is exposed only through its parent. It is not promoted to an unrelated global root.
The safe request object
The public request object has exactly
these fields:
| Field | Meaning |
|---|---|
request.design_mode | true in the theme editor. |
request.host | Accepted storefront host. |
request.locale | Active shop_locale object. |
request.origin | Storefront scheme and host. |
request.page_type | Current template/page type. |
request.path | Current storefront path when supplied. Narrow/error renders can use a fallback path such as /; guard it together with origin before building an absolute URL. |
request.visual_preview_mode | true in visual preview mode. |
<html lang="{{ request.locale.iso_code }}"> <body class="template-{{ request.page_type }}"> <p>{{ request.path }}</p> </body></html>Example output on a French product page:
<html lang="fr"> <body class="template-product"> <p>/fr/products/sandalwood-candle</p> </body></html>There is no public request.query, request.headers, request.cookies,
request.csrf_token, or editor-origin field. Use resource state such as
search.terms and collection.sort_by, or the narrow
sellerlane.query_state extension. Use {% form %}
for mutations; it injects the current CSRF input without revealing the token as
general template data.
This is aligned with Shopify’s current public
request object.
Nil, blank, and empty collections
Liquid distinguishes an absent object from an empty string or array. Sellerlane uses the documented meaning for each field instead of returning a truthy placeholder Drop.
| Situation | Result |
|---|---|
| Unknown Drop field | nil. |
Guest customer | nil. |
| Missing metafield namespace or key | nil. |
| Unknown/non-authored metaobject field | nil. |
| Missing, unpublished, or unauthorized single reference | nil. |
| Reference-list target that no longer exists | Omitted from the returned array; remaining items preserve authored order. |
No variants in product.first_available_variant | nil. |
No URL-selected variant in product.selected_variant | nil. |
| No assigned custom template suffix | nil. |
No sort_by query parameter on a collection | collection.sort_by is nil. |
| Supported list with no values | Empty array unless that object’s reference explicitly says nil. |
| Count above 25,000 | 25001, meaning “more than 25,000”. |
Test an object before traversing it:
{% assign care = product.metafields.details.care_instructions %}
{% if care %} <div>{{ care.value }}</div>{% else %} <div>No care instructions</div>{% endif %}Expected output when the namespace, key, or value is unavailable:
<div>No care instructions</div>Use default when an empty string, false, and nil should share a fallback;
use an explicit if when those cases have different business meaning.
Metafields
A metafield is resolved within its owning resource and has three public fields:
type, list?, and value. Access it by namespace and key:
{% assign material = product.metafields.details.material %}{% if material %} <p>Type: {{ material.type }}</p> <p>Material: {{ material.value }}</p>{% endif %}Example output:
<p>Type: single_line_text_field</p><p>Material: Organic cotton</p>Use bracket notation for a persisted namespace or key containing characters that aren’t convenient in dot notation:
{{ product.metafields['product-details']['wash-care'].value }}Reference metafields resolve to the same safe public Drops as normal page data. List references preserve authored order, deduplicate backend loading, and omit missing or unpublished targets:
{% assign related = product.metafields.custom.related_products %}{% if related.list? %} {% for related_product in related.value %} <a href="{{ related_product.url }}">{{ related_product.title }}</a> {% endfor %}{% endif %}Product, variant, collection, page, article, blog, file, location, and metaobject
references resolve only inside the current store. A storefront metafield never
hydrates customer_reference into a customer Drop: customer records are
account-scoped PII, so legacy or malformed customer references return nil.
See the metafield object,
metafields lookup, and Shopify’s
current metafield reference.
Metaobjects
The global metaobjects lookup accepts a syntactically valid, non-app-owned type
and returns a bounded definition container without first checking whether that
definition exists or is storefront-published. Entry lookups, values, and
values_count are backed by the storefront-filtered repository, so only entries
from storefront-visible definitions are returned. Resolve an entry by type and
handle. This example assumes material is configured to publish entries as web
pages:
{% assign cotton = metaobjects['material']['cotton'] %}
{% if cotton %} <article> <h2>{{ cotton['display-name'].value }}</h2> <p>Type: {{ cotton.system.type }}</p> <a href="{{ cotton.system.url }}">Read more</a> </article>{% endif %}Example output:
<article> <h2>Organic cotton</h2> <p>Type: material</p> <a href="/metaobjects/material/cotton">Read more</a></article>metaobject.system contains the entry’s reserved id, handle, type, and
normalized relative url. The URL falls back to /metaobjects/TYPE/HANDLE,
but that page route renders only for a definition with the online-store
capability. Authored field keys resolve to metafield objects. An authored field
wins over a legacy convenience name with the same spelling, while system
remains reserved. Unknown authored keys return nil; they never fall through
to the backend row.
Iterate arbitrary authored fields with metaobject.fields:
{% for field in metaobject.fields %} <dt>{{ field.key }}</dt> <dd>{{ field.value.value }}</dd>{% endfor %}metaobjects[type].values loads a bounded first set. Paginate it when a page
needs more entries. Link system.url only for a type whose definition has the
online-store capability:
{% paginate metaobjects.material.values by 24 %} {% for material in metaobjects.material.values %} <a href="{{ material.system.url }}">{{ material.name.value }}</a> {% endfor %} {{ paginate | default_pagination }}{% endpaginate %}The ordinary values loop loads at most 50 entries, a page can contain at most
250, and values_count uses the 25,001 sentinel. A definition resolves at most
20 unique entry handles in one render. App-owned, malformed, and over-budget
type lookups return nil. A valid but missing or non-storefront type remains a
truthy empty definition container: its values are empty, its values_count is
0, and its entry handles return nil. Draft or missing entry handles also
return nil.
As with resource metafields, a public metaobject never resolves a customer
reference into CustomerDrop, even for a legacy row. See the
metaobject object,
metaobject_definition,
and Shopify’s current metaobject reference.
Privacy and authorization boundaries
| Surface | Boundary |
|---|---|
| Anonymous storefront | Public Drops only; customer is nil. |
| Customer account | Customer-scoped Drops after OTP authentication. |
| Customer order page | The order must belong to the authenticated customer. |
| Signed delivery/order document | Narrow order capability, not a general customer or admin capability. |
| Visual-editor customer/order preview | Synthetic, redacted fixtures; never an arbitrary real buyer order. |
Public agents.md / llms.txt template | Buyer-independent public scope; no cart, customer, analytics, cookies, or raw seller shop DTO. |
| Analytics and pixels | Trusted platform bootstrap/private registers, not public Liquid roots. |
| Dynamic-source picker | Seller-owned field definitions combined with the buyer preview’s snapshot-pinned runtime scope graph; never live Drop values, cookies, or arbitrary request state. |
The seller backend owns definition publication and storefront visibility. The buyer backend revalidates store ownership, page/customer capability, locale URLs, lookup budgets, and the final public projection. Both checks are required; a seller response alone does not authorize a value for anonymous Liquid.
Request-scoped batching
Lazy data access is memoized within one render and batched where several Drops ask for the same resource family. Current batching covers:
- metafields across product-card/resource loops;
- product, variant, collection, page, article, and metaobject reference lists;
product.collectionsacross several product Drops;- linked product, collection, page, blog, article, and policy objects in menus;
- repeated lookup handles and repeated field access.
For example, this normal product-card loop is supported without one metafield request per card:
{% for product in collection.products %} <article> <h2>{{ product.title }}</h2> {% assign badge = product.metafields.card.badge %} {% if badge %}<span>{{ badge.value }}</span>{% endif %} </article>{% endfor %}Batching changes transport cost, not Liquid ordering. Results are returned in the merchant-authored order, missing targets follow the documented nil/list rule, and data from another store is discarded.
Lookup budgets
Lookup-style globals are intentionally bounded. Repeating the same key uses the memoized result and doesn’t consume another unique-key slot.
| Object | Unique lookups per Drop/render |
|---|---|
all_products[handle] | 20 |
collections[handle] | 20 |
blogs[handle] | 50 |
articles['blog/article'] | 20 |
pages[handle] | 20 |
linklists[handle] | 20 |
images['filename.ext'] | 20 |
metaobjects[type] | 20 types |
metaobjects[type][handle] | 20 handles per definition |
Malformed or over-budget keys return nil; missing and unpublished resource or
entry keys do too. The exception is metaobjects[type]: any syntactically valid,
non-app-owned type within budget returns a definition container, even when its
storefront-filtered list is empty. A lookup object isn’t a way to enumerate the
whole database; use its documented list or pagination surface where one exists.
Two-second render deadline
A storefront Liquid render has one two-second wall-clock budget. The budget is shared by parsing/evaluation and lazy repository work across the full page or requested fragment; it is not two seconds per Drop or per section. On expiry, the render fails and the shared abort signal cancels in-flight storefront data requests.
Theme authors should:
- paginate large arrays instead of walking an unbounded catalog;
- reuse an assigned lookup result instead of constructing many distinct keys;
- avoid nested loops whose work multiplies with catalog size;
- use supplied resource URLs and fields instead of repeated manual lookups; and
- keep Section Rendering requests focused on the sections that changed.
The hard deadline prevents a slow theme from occupying a buyer request indefinitely. It does not turn an incomplete render into partially trusted HTML.
Canonical meeting_status
Event reservation Liquid and public JSON use exactly one spelling:
meeting_status. No alternate camel-case alias is supported.
The public values are:
| Value | Meaning |
|---|---|
not_required | This reservation doesn’t need a provider-created online meeting. |
pending | Meeting creation is waiting to start. |
provisioning | The provider request is in progress. |
ready | Meeting details are prepared; the link may still be held until its reveal time. |
failed | Provisioning needs retry or seller action. |
canceled | The reservation/meeting is canceled. |
{% assign event = line_item.event_reservation %}
{% case event.meeting_status %} {% when 'ready' %} {% if event.join_url %} <a href="{{ event.join_url }}">Join meeting</a> {% else %} <p>Your join link will appear at {{ event.reveals_at | date: '%c' }}.</p> {% endif %} {% when 'pending', 'provisioning' %} <p>Your meeting link is being prepared.</p> {% when 'failed' %} <p>The seller has been asked to fix the meeting link.</p> {% when 'canceled' %} <p>This booking was canceled.</p>{% endcase %}Possible output before the reveal window opens:
<p>Your join link will appear at Tue Jul 14 10:00:00 2026.</p>event.status is the reservation lifecycle and remains separate from
event.meeting_status. join_url is nil unless the reservation is confirmed,
not canceled, the reveal boundary has opened, and the URL is a valid public
HTTP(S) URL. A stale payload cannot make cancellation false or reveal a link
early.