Skip to content

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 Candle
Sandalwood Candle
Unknown fields are nil

Adding 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.

RootValue
shopCurated public store information.
settingsValues from config/settings_data.json.
templatename, optional suffix, and optional directory.
requestThe seven safe request fields documented below.
routesLocale-aware built-in storefront URLs.
cartThe current visitor cart.
customerAuthenticated customer Drop, or nil for a guest.
sellerlaneNamespaced Sellerlane extensions such as narrow query state and recommendations.
all_productsBounded product lookup by handle.
collectionsEnumerable collections plus bounded handle lookup.
blogsBounded blog lookup by handle.
articlesBounded blog-handle/article-handle lookup.
pagesEnumerable pages plus bounded handle lookup.
linklistsBounded navigation-menu lookup by handle.
metaobjectsBounded lookup of syntactically valid metaobject type containers; their entries are storefront-filtered.
imagesBounded theme/store image lookup by validated filename.
current_pageCurrent paginator page number.
handleCurrent 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

ContextAdditional roots
Product templateproduct; collection when reached through a collection context.
Collection templatecollection, current_tags.
Blog templateblog, current_tags.
Article templatearticle, blog.
Page templatepage.
Search templatesearch.
Metaobject web-page templatemetaobject.
Predictive search endpointpredictive_search.
Product recommendations endpointContextual recommendations result.
Customer accountAuthenticated customer; an authorized order only on its order context.
Robots routerobots.
Gift-card routeCapability-scoped gift_card.
{% form %} bodyform.
{% paginate %} bodypaginate.

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:

FieldMeaning
request.design_modetrue in the theme editor.
request.hostAccepted storefront host.
request.localeActive shop_locale object.
request.originStorefront scheme and host.
request.page_typeCurrent template/page type.
request.pathCurrent 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_modetrue 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.

SituationResult
Unknown Drop fieldnil.
Guest customernil.
Missing metafield namespace or keynil.
Unknown/non-authored metaobject fieldnil.
Missing, unpublished, or unauthorized single referencenil.
Reference-list target that no longer existsOmitted from the returned array; remaining items preserve authored order.
No variants in product.first_available_variantnil.
No URL-selected variant in product.selected_variantnil.
No assigned custom template suffixnil.
No sort_by query parameter on a collectioncollection.sort_by is nil.
Supported list with no valuesEmpty array unless that object’s reference explicitly says nil.
Count above 25,00025001, 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

SurfaceBoundary
Anonymous storefrontPublic Drops only; customer is nil.
Customer accountCustomer-scoped Drops after OTP authentication.
Customer order pageThe order must belong to the authenticated customer.
Signed delivery/order documentNarrow order capability, not a general customer or admin capability.
Visual-editor customer/order previewSynthetic, redacted fixtures; never an arbitrary real buyer order.
Public agents.md / llms.txt templateBuyer-independent public scope; no cart, customer, analytics, cookies, or raw seller shop DTO.
Analytics and pixelsTrusted platform bootstrap/private registers, not public Liquid roots.
Dynamic-source pickerSeller-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.collections across 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.

ObjectUnique 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:

ValueMeaning
not_requiredThis reservation doesn’t need a provider-created online meeting.
pendingMeeting creation is waiting to start.
provisioningThe provider request is in progress.
readyMeeting details are prepared; the link may still be held until its reveal time.
failedProvisioning needs retry or seller action.
canceledThe 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.