Dynamic catalog data through Liquid search
A merchant needs a large architectural lighting configurator to request only the selected product data without embedding the complete sibling catalog in the initial page.
Known limits
- Data access
- The search template can expose only catalog data available to the storefront.
- Route caching
- Cache behavior for alternate search views still needs a two request check on each deployed theme.
- Projection size
- Each response returns at most 120 option rows, so larger families need narrower requests.
- Variant access
- Products whose complete variant arrays are unavailable return an error and need a different data path.
Original reference implementation. Not client source code.
Why a search template
A Shopify Function cannot supply data to a product page. The first theme implementation embedded every sibling product and variant in JSON inside the initial document. That made both HTML size and browser work grow with choices the shopper had not requested, eventually making the page unresponsive.
Section Rendering moved the work out of the initial document, but requests against a fixed resource returned stale dynamic payloads in the observed integration. A Storefront API client could query products directly, but it would duplicate theme visibility and presentation rules. An app proxy could avoid that duplication only by moving those rules and availability into another service.
The alternate Liquid search template kept the data in the theme while giving each selection a distinct search request. Shopify documents that alternate templates use the view parameter and that a search template receives the query as search.terms. The route cache behavior remains an observed integration fact, so the repository includes a manual check rather than presenting it as a Shopify guarantee.
Design
The initial page contains only the current product and a small request map. Selecting a fixture requests /search?view=fixture_data&type=product&q=v1%7Caurora-track%7CMedium. The template uses layout none, validates the three request segments, resolves one product, and returns raw JSON. Because Shopify exposes at most 250 unpaginated variants, the template rejects an incomplete Liquid variant window instead of returning partial data. It also serializes variant IDs as strings. The response echoes the handle and size so the browser can reject a stale or misrouted payload before rendering.
The deliberate wrong answer uses a test limit of four rows with five valid Medium options. The endpoint returns projection_too_large instead of the five options a merchant would prefer. Returning a partial list would hide sellable merchandise, while removing the guard could recreate the oversized payload that caused the page to become unresponsive. The published default is 120 rows, and a larger family must be split into narrower requests.
This search view turns search.terms into one validated projection request.
{%- layout none -%}
{%- assign request_parts = search.terms | split: '|' -%}
{%- assign schema_version = request_parts[0] | strip -%}
{%- assign product_handle = request_parts[1] | strip -%}
{%- assign size_key = request_parts[2] | strip -%}
{%- assign canonical_handle = product_handle | handleize -%}
{%- assign maximum_options = 120 -%}
{%- assign invalid_request = false -%}
{%- if request_parts.size != 3 or schema_version != 'v1' -%}
{%- assign invalid_request = true -%}
{%- elsif product_handle == blank or product_handle != canonical_handle -%}
{%- assign invalid_request = true -%}
{%- elsif product_handle.size > 64 or size_key == blank or size_key.size > 48 -%}
{%- assign invalid_request = true -%}
{%- endif -%}
{%- if invalid_request -%}
{%- render 'projection_error', code: 'invalid_request' -%}
{%- else -%}
{%- assign requested_product = all_products[product_handle] -%}
{%- render 'fixture_projection',
product: requested_product,
size_key: size_key,
maximum_options: maximum_options
-%}
{%- endif -%}This projection rejects missing products and oversized results before emitting any option row.
{%- if product == blank -%}
{%- render 'projection_error', code: 'not_found' -%}
{%- elsif product.variants_count > product.variants.size -%}
{%- render 'projection_error', code: 'variant_window_incomplete' -%}
{%- else -%}
{%- assign matching = product.variants | where: 'option1', size_key -%}
{%- if matching.size > maximum_options -%}
{%- render 'projection_error',
code: 'projection_too_large',
limit: maximum_options
-%}
{%- else -%}
{
"context": {
"handle": {{ product.handle | json }},
"size": {{ size_key | json }}
},
"options": [
{%- render 'fixture_rows', variants: matching -%}
]
}
{%- endif -%}
{%- endif -%}This row renderer uses Liquid JSON encoding and controls commas without adding HTML wrappers.
{%- for variant in variants -%}
{%- unless forloop.first -%},{%- endunless -%}
{
"variantId": {{ variant.id | append: '' | json }},
"finish": {{ variant.option2 | json }},
"available": {{ variant.available | json }},
"price": {{ variant.price | json }}
}
{%- endfor -%}This browser function bounds the response, recognizes endpoint errors, and verifies both its shape and echoed context.
export async function loadProjection(
input: ProjectionInput,
fetcher: typeof fetch,
): Promise<Projection> {
assertProjectionInput(input);
const query = new URLSearchParams({
view: "fixture_data",
type: "product",
q: ["v1", input.handle, input.size].join("|"),
});
const response = await fetcher(`/search?${query.toString()}`);
if (!response.ok) throw new Error(`http_${response.status}`);
const raw = (await response.text()).trim();
if (raw.length > 60_000) {
throw new Error("response_too_large");
}
const value: unknown = JSON.parse(raw);
if (isProjectionError(value)) throw new Error(value.error.code);
if (!isProjection(value)) throw new Error("invalid_projection");
if (!sameContext(value.context, input)) throw new Error("context_mismatch");
return value;
}What the tests cover
The offline suite renders the Liquid snippets with local product fixtures and compatible json and handleize filters. It checks exact request parsing, product lookup, complete variant visibility, size filtering, comma placement, JSON escaping, string IDs, empty results, row limits, error envelopes, response bounds, response shape, context echoes, and context mismatch rejection. A separate development theme procedure requests two distinct query values and confirms that the response echoes change.
This test pins the bounded error instead of silently truncating the fifth valid option.
it("pins an error when five rows exceed a limit of four", async () => {
const output = await renderProjection({
handle: "aurora-track",
size: "Medium",
maximumOptions: 4,
variants: [
variant("4101", "Medium", "Black"),
variant("4102", "Medium", "Brass"),
variant("4103", "Medium", "Nickel"),
variant("4104", "Medium", "White"),
variant("4105", "Medium", "Bronze"),
],
});
expect(JSON.parse(output)).toEqual({
error: { code: "projection_too_large", limit: 4 },
});
});Reading the source
Start with templates/search.fixture_data.liquid to see why the search route is the data boundary. Then read snippets/fixture_projection.liquid and snippets/fixture_rows.liquid for the bounded response. src/loadProjection.ts is intentionally small because browser caching and state management are not the subject of this artifact.
Want to see how this holds up in production?
The case studies are the same problems with a client, a deadline, and a legacy codebase attached.