BizzPocket

Data: data(), load() and the data helpers

On this page

A block that shows products, a menu or the store name needs data. You do not fetch it yourself. You describe what you need in data(). The page collects the needs of every block and fetches them for you, in one request, before it renders anything.

tsx
data: ({ collection, count }) => ({
  items: products({ collection, first: count }),
}),
render: ({ title, items }) => /* items is the result */,

data() is a plain function. It receives the block's props (your fields) and the page, and returns an object. Every value in that object is the result of a data helper such as products(). The helpers do not fetch anything when you call them. They return a small description ("the first 4 products of this collection"). The names you choose (items above) become props of render.

Contents: Helpers · Where values come from · Static and runtime · Rules for static · Why is my block runtime? · Handling null · Limits · load() · Fetching from the browser · lcp and aboveFold


The data helpers

Import them from @bizzpocket/theme:

ts
import { products, product, related, categories, menu, store, home, search, gql } from '@bizzpocket/theme';
Helper Arguments (* = required) Result type (or null)
products({ collection?, first*, sort?, page?, filters? }) first is how many products ProductGridResult: { products, collection, total, page, totalPages, facets, priceRange }
product({ handle* }) the product's handle (its URL name) ProductDetail: title, price, variants, images, description, rating...
related({ productId*, first* }) a product id and how many ProductSuggestion[]
categories({ depth* }) how many levels to load CategoryTree: { categories }
menu({ handle* }) a navigation menu handle, such as 'main' NavigationMenu: { handle, title, items }
store() none StoreProfile: name, logo, currency, contact details, policies
home() none StoreHome: the store's home page settings
search({ q*, first?, page? }) the search text ProductSearchResult: { hits, total, facets }
gql(document, variables?) a GraphQL document string whatever you type it as (gql<MyType>(...))

The result types are exported from @bizzpocket/theme too (Product, ProductDetail, NavigationMenu and so on), so you can name them in your own components.

A need whose required argument is missing or empty (for example menu({ handle }) while handle is empty) is not fetched, and the block receives null for it.

A Product (the item inside products, hits and related) looks like this:

ts
{ id, handle, title, price: { amount, currency }, compareAtPrice?: { amount, currency }, image?: { src, alt } }

Where the arguments come from

data(props, page) takes two parameters.

  • props holds the block's saved field values. A prop the seller never set has the field's default (see fields.md). Read props as p.product, not by destructuring ({ product }): a destructured name that matches a helper (product, products, menu...) hides the imported helper, and data() stops being static.
  • page.route describes the page being shown:
page.route. Meaning
pageType 'home', 'product', 'shop', 'category', 'collection', 'cart', 'search'...
handle the product, category or collection handle in the URL
q the search text (?q=)
page the page number (?page=)
sort the sort order (?sort=)
filters every other query parameter, as { name: value }

A product page block can read the product handle from the route. Create blocks/product-title/product-title.tsx:

blocks/product-title/product-title.tsx
import { block, product } from '@bizzpocket/theme';

export default block({
  name: 'Product title',
  category: 'commerce',
  data: (_props, page) => ({
    item: product({ handle: page.route.handle }),
  }),
  render: ({ item }) => (item ? <h1>{item.title}</h1> : null),
});

page.route.handle is string | undefined, and the helpers take that as it is: if the handle is missing the need is skipped and item is null. (Do not write ?? '' or ! here. They make the block runtime. See below.)

A search page block uses q and page the same way. Create blocks/search-hits/search-hits.tsx:

blocks/search-hits/search-hits.tsx
import { block, search } from '@bizzpocket/theme';

export default block({
  name: 'Search hits',
  category: 'products',
  data: (_props, page) => ({
    results: search({ q: page.route.q, first: 24, page: page.route.page }),
  }),
  render: ({ results }) => (
    <ul>
      {(results?.hits ?? []).map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  ),
});

Several needs in one block are fine. They are fetched together. Create blocks/shop-banner/shop-banner.tsx:

blocks/shop-banner/shop-banner.tsx
import { block, f, menu, store, categories } from '@bizzpocket/theme';

export default block({
  name: 'Shop banner',
  category: 'header',
  fields: { menuHandle: f.menu({ default: 'main' }) },
  data: ({ menuHandle }) => ({
    shop: store(),
    // A `f.menu()` field is `string | undefined`; unset, the need is skipped and `nav` is null.
    nav: menu({ handle: menuHandle }),
    tree: categories({ depth: 2 }),
  }),
  render: ({ shop, nav, tree }) => (
    <header>
      <strong>{shop?.name}</strong>
      <nav>
        {(nav?.items ?? []).map((i) => (
          <a key={i.id} href={i.href}>{i.label}</a>
        ))}
      </nav>
      <small>{tree?.categories.length ?? 0} categories</small>
    </header>
  ),
});

Static and runtime data

Every block that has data() is one of two kinds. You can see which in the build's size table, in the data column (and in dist/size-report.json).

static (best). The build reads your data() once, while building, and turns it into a small template: "needs products with collection = the block's collection prop and first = its count prop". The template ships in dist/templates.json. When a shopper opens a page, the server fills in each block's saved props and fetches everything the page needs in a single call. Your data() function does not run on the server at all.

runtime (works, but costs one extra fetch). The build could not turn data() into a template, so it keeps the function. The page is built first, then your data() runs for the block and its needs are fetched in one more round. Nothing breaks. The page is a little slower. It is the reason to keep data() simple.

The build never guesses. If it is not sure a template would behave exactly like your function, it chooses runtime.

block                server B  client gz  css gz  data
-------------------  --------  ---------  ------  -------
featured-collection      1490          0     213  static
promo                     310          0       0  runtime
hello                     292          0       0  none

why runtime (see docs/theme-sdk/data.md, "Why is my block runtime?"):
  blocks/promo/promo.tsx:9  ternary (?:) in data() [data-not-static]

none means the block has no data() and no load(). Every runtime block gets a line under the table saying why (Why is my block runtime?).


The rules for static data

A block's data is static when all of these are true.

1. The file has the expected shape. The default export is export default block({ ... }), written directly. Also fine: const x = block({ ... }); export default x; when x is used nowhere else. The object has no ...spread, no computed keys ([name]: ...), and exactly one data property: an inline function, or a name that points to a top-level const arrow function.

2. block and the helpers come from @bizzpocket/theme. import { block, products } from '@bizzpocket/theme'. Renaming is fine (import { products as getProducts }). Importing from @bizzpocket/theme/server is also fine. A star import (import * as H) followed by H.products() is not.

3. Helpers are called as bare calls. products({ ... }). Not H.products(), not obj.products(), and not a function of your own that calls products inside.

Do not name a field like a helper. A field called product, products, menu, store, categories, home, search, related or gql hides the helper of that name inside data() as soon as you destructure it: in data: ({ product }) => ({ item: product({ handle: product }) }), product is the seller's string, not the helper, so the call fails (or the block goes runtime with helper-not-imported). Name the field for what it holds: productHandle, menuHandle, collection.

4. data() only reads parameters, helpers and literal top-level constants. The parameters are props and page. A literal constant is a top-level const set to a string, a number, true, false or null. Create blocks/trending/trending.tsx:

blocks/trending/trending.tsx
import { block, f, products } from '@bizzpocket/theme';

const HOW_MANY = 8; // a literal top-level const is allowed

export default block({
  name: 'Trending',
  category: 'products',
  fields: { collection: f.collection() },
  data: ({ collection }) => ({
    items: products({ collection, first: HOW_MANY, filters: { colour: 'red' } }),
  }),
  render: ({ items }) => (
    <ul>
      {(items?.products ?? []).map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  ),
});

5. No branching or computing on values. Props and route values may only be passed through into helper arguments. You may not test them, combine them or change them. That rules out if, ? :, &&, ||, ??, +, -, !, [...], template strings with ${}, typeof, calling methods on a prop, and const x = ... inside the function. (A literal object such as filters: { colour: 'red' } is fine, because it does not read a prop.)

6. data() is synchronous. No async, no await, no fetch.

On top of reading the source, the build runs your function many times with different probe values (empty, zero, true, arrays...) and compares the results with the template. If any probe disagrees, the block is runtime. You do not need to do anything for this. It is the safety net for code that looks simple but is not.


Why is my block runtime?

Build your theme. A block whose data column says runtime gets a line under the table: the file and line, what stops it being static, and the reason code in brackets.

block           server B  client gz  css gz  data
--------------  --------  ---------  ------  -------
accent               352          0       0  runtime
branchy              361          0       0  runtime
loader               229          0       0  runtime
shadow               300          0       0  runtime
total                             0       0

why runtime (see docs/theme-sdk/data.md, "Why is my block runtime?"):
  blocks/accent/accent.tsx:7    data() reads color field "accent" (un-coercible kind) [field-kind]
  blocks/branchy/branchy.tsx:8  ternary (?:) in data() [data-not-static]
  blocks/loader/loader.tsx:5    load() runs on every request (runtime by design) [load]
  blocks/shadow/shadow.tsx:6    destructured name "product" shadows the helper in data() [helper-not-imported]

The same three facts are in dist/size-report.json, on each block: "reason" (the code), "detail" (the text) and "at" (blocks/<folder>/<file>:<line>; no line when the build cannot point at one). bizzpocket build --json prints that report instead of the table (warnings go to stderr), for scripts and CI:

bash
bizzpocket build --json | jq '.blocks[] | select(.data == "runtime") | "\(.at) \(.detail)"'

Each reason and its fix

The line says What it means Fix
load() runs on every request (runtime by design) [load] The block has load(), which is always runtime and never cached. The line is the load key. Expected. See load().
data() reads <kind> field "<name>" (un-coercible kind) [field-kind] A helper argument reads a prop of a kind the server cannot clean the way readProps does: color, link, richText, image, video, list, group or blocks. The line is where data() reads it. Pass only number, text, select, boolean, product / collection / category / menu, products / categories and json props into helper arguments. Use the other kinds in render().
data() reads "<name>", which is not a field of the block [field-kind] data() reads a prop no field declares (a typo, or a field that was removed). Add the field, or fix the name.
data() reads trait "<name>" ... [field-kind] Traits are CSS variables, not props. Do not pass a trait to a helper.
ternary (?:) in data(), `&&` in data(), `if` in data() [data-not-static] A choice that depends on a value. Always return the need. A missing value makes the need null, which render handles (example below).
`*` on a value in data(), template string in data(), `!` in data() Computing with a value. Pass the value through. Do the maths in render, or add a field that holds the final number.
array literal `[...]` in data() [ and ] are not allowed in a static data(). Use an object, or filter in render from the result.
reading inside a value (`a.b`) in data() props.collection.slug: reading inside a value. Pass the whole value. Make slug its own field.
`LIMIT` (not a parameter or a literal const) in data() data() reads an outside value that is not a literal top-level const (computed, or imported). Use a literal (const LIMIT = 8), or a field (f.number({ default: 8 })).
call to `myHelper()` (not a @bizzpocket/theme helper) in data() Only the built-in helpers may be called. Inline the helper call.
call that is not a bare helper call in data() H.products(...) after import * as H, or obj.products() (rule 3). import { products } and call products(...).
destructured name "product" shadows the helper in data() [helper-not-imported] A field named like a helper, destructured in data(), hides the helper: product(...) calls the seller's string. Rename the field (productHandle).
"products" declared in this file shadows the helper in data() [helper-not-imported] Your own const products = ... (a wrapper) or an import of that name hides the helper. Delete the wrapper and call the real helper. To share arguments, share a plain object, not a function.
`const` declaration in data(), `await` in data(), `new` in data(), async data() Not allowed in a static data() (rules 5 and 6). Remove them.
computed value: data() reads into, compares or computes on a prop or route value The build's sample runs caught a value being inspected, and the source scan did not find where. The line is the data key. Look for a method call, a comparison or a conversion on a prop (String(x), x.length).
what data() returns depends on the values passed in (a branch) The sample runs got different needs for different values. As for a ternary: always return the same needs.
the block file is not in the analysable shape ... Rule 1: not export default block({ ... }) (or one export const X = block({ ... })), a ...spread, a computed key, or data from another file. Write export default block({ ... }) with an inline data.

Example of an outside value that is computed. Create blocks/computed-limit/computed-limit.tsx:

blocks/computed-limit/computed-limit.tsx
import { block, f, products } from '@bizzpocket/theme';

const LIMIT = Number('8'); // computed, not a literal

export default block({
  name: 'Computed limit',
  category: 'products',
  fields: { collection: f.collection() },
  data: ({ collection }) => ({ items: products({ collection, first: LIMIT }) }),
  render: ({ items }) => <p>{items?.total}</p>,
});

Change Number('8') to 8 and the block becomes static.

The shapes in detail

A block that is runtime because of a branch, and its static fix. Create blocks/branchy/branchy.tsx:

blocks/branchy/branchy.tsx
import { block, f, products } from '@bizzpocket/theme';

export default block({
  name: 'Branchy',
  category: 'products',
  fields: { collection: f.collection() },
  // Runtime: what is returned depends on the value of `collection`.
  data: ({ collection }) => ({ items: collection ? products({ collection, first: 4 }) : undefined }),
  render: ({ items }) => <p>{items?.total}</p>,
});

Create blocks/not-branchy/not-branchy.tsx:

blocks/not-branchy/not-branchy.tsx
import { block, f, products } from '@bizzpocket/theme';

export default block({
  name: 'Not branchy',
  category: 'products',
  fields: { collection: f.collection() },
  // Static: always describe the need. `render` decides what to show.
  data: ({ collection }) => ({ items: products({ collection, first: 4 }) }),
  render: ({ items }) => (items ? <p>{items.total} products</p> : null),
});

Renaming a helper on import is fine. Create blocks/aliased/aliased.tsx:

blocks/aliased/aliased.tsx
import { block, f, products as getProducts } from '@bizzpocket/theme';

export default block({
  name: 'Aliased',
  category: 'products',
  fields: { collection: f.collection() },
  data: ({ collection }) => ({ items: getProducts({ collection, first: 4 }) }),
  render: ({ items }) => <p>{items?.total}</p>,
});

Results can be null

Every helper result can be null. A need is null when:

  • a required argument is missing or empty (for example no handle),
  • the store has nothing for it (the product does not exist),
  • the fetch failed or timed out. The page then renders without it instead of failing.

So write render to cope with null, and show nothing instead of a heading over an empty list:

tsx
render: ({ title, items }) => {
  const list = items?.products ?? [];
  if (list.length === 0) return null; // no heading over an empty grid
  return <section><h2>{title}</h2>{/* ... */}</section>;
},

Returning null from render is the normal way to render nothing.


Limits

  • Needs cannot use each other's results. All needs of a page are fetched at the same moment, so related({ productId }) cannot take the id from a product() in the same block. Pass an id from a field or the route, or use load().
  • Prefer static gql() blocks. A static gql() need is fetched with the rest of the page. In a runtime block the Bun storefront runs the document itself, as the shopper, in the block's own round of fetches (one extra request); the result is the document's single top-level field, as for a static block. Follow rules 1 to 6 and type the result yourself. Create blocks/top-categories/top-categories.tsx:
blocks/top-categories/top-categories.tsx
import { block, f, gql } from '@bizzpocket/theme';

type Tree = { categoryTree: { categories: Array<{ id: string; title: string; href: string }> } };

export default block({
  name: 'Top categories',
  category: 'collections',
  fields: { heading: f.text({ default: 'Shop by category' }) },
  data: () => ({
    tree: gql<Tree>('query TopCategories($depth: Int!) { categoryTree(depth: $depth) { categories { id title href } } }', {
      depth: 1,
    }),
  }),
  render: ({ heading, tree }) => (
    <nav>
      <h2>{heading}</h2>
      {(tree?.categoryTree.categories ?? []).map((c) => (
        <a key={c.id} href={c.href}>{c.title}</a>
      ))}
    </nav>
  ),
});

gql() is typed by you (the <Tree> above): nothing checks the document against the storefront schema while you build, so run it in bizzpocket dev to see that it returns what you expect.

  • data and load are exclusive. Setting both throws Block "..." sets both data and load.
  • Props are read with their defaults. data() sees the saved prop, else the field's default, so a block that was never edited still fetches something sensible.
  • Identical needs are fetched once per page, even from different blocks.

load(): the escape hatch

Use load() when the data depends on the shopper, or on logic that data() cannot express. It is async and runs on the server for every request. Create blocks/greeting/greeting.tsx:

blocks/greeting/greeting.tsx
import { block } from '@bizzpocket/theme';

type StoreProfile = { name: string } | null;

export default block({
  name: 'Greeting',
  category: 'content',
  async load(ctx) {
    // ctx.store.slug is the store being shown.
    const profile = (await ctx.query('storeProfile', { storeSlug: ctx.store.slug })) as StoreProfile;
    return { shopName: profile?.name ?? ctx.store.slug };
  },
  // load()'s result is typed in `render`: `shopName` is a string here.
  render: ({ shopName }) => <p>Welcome to {shopName ?? ''}</p>,
});

What you get in ctx:

Member What it is
ctx.props the block's props (field values, with defaults)
ctx.route the page: pageType, handle, q, page, sort, filters
ctx.store.slug the store's slug
ctx.query(op, vars) runs one named storefront operation as the current shopper. Resolves to the result, or null on any error

The operations ctx.query knows are productGridProducts, storeProfile, storeHome, categoryTree, navigationMenu, productDetail, productSuggestions and productSearch. An unknown name resolves to null. The variables are the operation's own (for productDetail: { handle }).

Rules for load():

  • Whatever it returns (an object) is merged into render's props. Return plain data.
  • It is never cached: it runs per request and per shopper. A load() block always shows as runtime in the size table.
  • It has a time limit. If it throws or is too slow, the block gets {} and the page is marked degraded, so render must cope with missing keys (the types say they are there; at runtime they may not be, hence the ?? '' above).
  • data() and load() cannot be used in the same block.
  • Prefer data() whenever you can. It is faster and cacheable.

Fetching from the browser

data() and load() run on the server, before the page is sent. Some data is wanted only after the shopper acts: suggestions while they type, the next page of a grid. An island fetches that with useQuery(op, vars, { enabled }) from @bizzpocket/theme/client (the hook itself is described in interactivity.md).

A "load more" button for a collection: page 1 comes with the page from data(), and every further page comes from the browser when the shopper asks for it. Create blocks/more-products/more-list.tsx:

blocks/more-products/more-list.tsx
'use client';
import { useEffect, useState } from 'react';
import { useQuery } from '@bizzpocket/theme/client';

type Item = { id: string; handle: string; title: string };
type Grid = { products: Item[]; page: number; totalPages: number };

export function MoreList({ collection, first, totalPages }: { collection: string | null; first: number; totalPages: number }) {
  const [shown, setShown] = useState(1); // pages on screen; page 1 is the server's
  const [asked, setAsked] = useState<number | null>(null); // the page being fetched
  const [extra, setExtra] = useState<Item[]>([]);
  const { data, error } = useQuery<Grid>(
    'productGridProducts',
    { collection: collection ?? undefined, page: asked, limit: first },
    { enabled: asked !== null },
  );
  useEffect(() => {
    if (asked === null || data?.page !== asked) return;
    setExtra((xs) => [...xs, ...data.products]);
    setShown(asked);
    setAsked(null);
  }, [asked, data]);
  return (
    <>
      <ul>
        {extra.map((p) => (
          <li key={p.id}>
            <a href={`/products/${encodeURIComponent(p.handle)}`}>{p.title}</a>
          </li>
        ))}
      </ul>
      {error !== null && <p role="alert">Could not load more ({error.code}).</p>}
      {shown < totalPages && (
        <button type="button" disabled={asked !== null} onClick={() => setAsked(shown + 1)}>
          {asked !== null ? 'Loading...' : 'Load more'}
        </button>
      )}
    </>
  );
}

And the block, blocks/more-products/more-products.tsx:

blocks/more-products/more-products.tsx
import { block, f, products } from '@bizzpocket/theme';
import { MoreList } from './more-list';

export default block({
  name: 'More products',
  category: 'products',
  fields: {
    collection: f.collection(),
    count: f.number({ default: 8, min: 1, max: 24 }),
  },
  data: ({ collection, count }) => ({ items: products({ collection, first: count }) }),
  render: ({ collection, count, items }) => (
    <section>
      <ul>
        {(items?.products ?? []).map((p) => (
          <li key={p.id}>
            <a href={`/products/${encodeURIComponent(p.handle)}`}>{p.title}</a>
          </li>
        ))}
      </ul>
      <MoreList collection={collection ?? null} first={count} totalPages={items?.totalPages ?? 1} />
    </section>
  ),
});

The variables are those of products()'s operation: collection, page, limit, sort and filters.

The route behind it is GET /_bp/q/<op>?v=<variables as JSON>, on the storefront's own address.

  • Which operations. Only public, read-only catalog and store data:

    • products: productGridProducts, productSearch, productDetail, productSuggestions and productFrames;
    • categories and menus: categoryTree, categoryTreeWithFeatured, categoryFilters and navigationMenu;
    • the store: storeProfile, storeHome, storePromotions and gemCertificate.

    The list is exported as BROWSER_QUERY_OPS, and useQuery's first argument is typed with it. Account data (customerMe, orders, addresses, the wishlist) is never served. Read it in load(), or use useCustomer() / useWishlist(). Any other name gets 404 with { "error": { "code": "NOT_FOUND" } }.

  • Variables. They are the operation's own, as for ctx.query, and must be a JSON object of at most 4 KB.

    • Bad JSON is 400 BAD_VARS.
    • Too long is 413 VARS_TOO_LARGE; the hook says so without sending the request.
    • storeSlug is always the store being shown, whatever you pass.
  • Results. The answer is { "data": <the operation's result> }. A failure is a bare code: UPSTREAM (502) when the store's API failed, INTERNAL (500). Nothing else about the failure is passed on.

  • Caching.

    • In the browser, each (operation, variables) answer is kept in memory for the visit, shared by every island. refetch() and clearQueryCache() skip or empty that cache.
    • On the network, an answer to a request without cookies may be cached for 30 seconds (public, max-age=30, stale-while-revalidate=60).
    • With cookies (a signed-in shopper, whose prices may be their own), the answer is private, no-store.

Prefer data() whenever the data is known when the page is built. It is in the first HTML, costs no JavaScript and needs no spinner.


lcp and aboveFold

tsx
block({ name: 'Hero', lcp: true /* or aboveFold: true */, /* ... */ })

Mark the block that holds the page's biggest visible element (usually the hero image; this is the LCP, Largest Contentful Paint) with lcp: true, and other blocks that are visible without scrolling with aboveFold: true.

  • The page waits for these blocks' data before it sends the first part of the HTML, so they are never an empty box that fills in later. Other blocks stream in when their data arrives.
  • Their data travels in the first part of the page call.
  • Use it sparingly. Every critical block delays the first byte until its data is ready.
  • Put priority on the main <Image> of an lcp block (see first-block.md).

URL and image helpers

Fields are cleaned for you: an f.link() value is always a safe link and an f.image() value a safe picture. Data is not. A URL that comes from the store (a category's href, a social link, a breadcrumb) can be anything the seller or an import wrote, so check it before it goes into href or src. These come from @bizzpocket/theme, have no imports, and work in islands too:

tsx
import { isSafeMediaUrl, isSafeUrl, responsiveImage, toSafeUrl } from '@bizzpocket/theme';

const href = toSafeUrl(category.href); // the trimmed link, or null
{href !== null && <a href={href}>{category.title}</a>}

{isSafeMediaUrl(photo.src) && <img {...responsiveImage({ src: photo.src, sizes: '(max-width: 640px) 100vw, 50vw' })} alt="" width={800} height={600} />}
Helper Returns
isSafeUrl(url) true for a root-relative path (/shop, never //host or /\host), a #anchor, or an https: / mailto: / tel: URL. Control characters always fail.
toSafeUrl(value) The value with its edge whitespace trimmed when isSafeUrl accepts it; otherwise null (non-strings too).
isSafeMediaUrl(url) true for https: or a root-relative path only: what an <img> / <video> src may be.
responsiveImage({ src, sizes, maxWidth?, widths? }) { src, srcSet?, sizes? } to spread on an <img>. BizzPocket CDN and Unsplash images get a srcset (the CDN's own width ladder); any other host gets { src } back unchanged. It never throws.

These are the same rules theme-sdk uses (isSafeUrl, toSafeUrl, SafeUrlSchema, responsiveImage); a test keeps the two in step. <Link> and <Image> already apply them, so you only need the helpers when you write a plain <a> or <img>.

Next: interactivity.md.