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.
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:
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:
{ id, handle, title, price: { amount, currency }, compareAtPrice?: { amount, currency }, image?: { src, alt } }Where the arguments come from
data(props, page) takes two parameters.
propsholds the block's saved field values. A prop the seller never set has the field's default (see fields.md). Read props asp.product, not by destructuring({ product }): a destructured name that matches a helper (product,products,menu...) hides the imported helper, anddata()stops being static.page.routedescribes 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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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 aproduct()in the same block. Pass an id from a field or the route, or useload(). - Prefer static
gql()blocks. A staticgql()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. Createblocks/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.
dataandloadare exclusive. Setting both throwsBlock "..." 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:
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 asruntimein the size table. - It has a time limit. If it throws or is too slow, the block gets
{}and the page is marked degraded, sorendermust cope with missing keys (the types say they are there; at runtime they may not be, hence the?? ''above). data()andload()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:
'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:
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,productSuggestionsandproductFrames; - categories and menus:
categoryTree,categoryTreeWithFeatured,categoryFiltersandnavigationMenu; - the store:
storeProfile,storeHome,storePromotionsandgemCertificate.
The list is exported as
BROWSER_QUERY_OPS, anduseQuery's first argument is typed with it. Account data (customerMe, orders, addresses, the wishlist) is never served. Read it inload(), or useuseCustomer()/useWishlist(). Any other name gets404with{ "error": { "code": "NOT_FOUND" } }. - products:
-
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. storeSlugis always the store being shown, whatever you pass.
- Bad JSON is
-
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()andclearQueryCache()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.
- In the browser, each (operation, variables) answer is kept in memory for the visit, shared by every island.
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
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
priorityon the main<Image>of anlcpblock (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:
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.