QUESTPIE
SchemaBlocks

Loading data for a block

A block stores an id. `.prefetch()` turns it into the record on the way out, or runs a query of your own, once per read and batched across the page.

View markdown

The renderer needs a URL and the column holds "asset_77". This page covers the three ways to close that gap on the server, what each one costs a read, and when the work does not run at all.

Expanding a field you already store

Name the fields to expand. Each one is fetched and placed under _data[blockId], beside the id that is still in _values.

.prefetch({ with: { backgroundImage: true } })

Only relation and upload fields expand. A name in with that is not one of those is skipped in silence, never half resolved. Expansions are batched by target collection, field kind and nested with, so twenty heroes carrying one upload each cost one query, not twenty.

To go a level deeper, nest with. The inner object is handed straight to the target collection's find, so it behaves exactly like a normal relation load.

.prefetch({ with: { author: { with: { avatar: true } } } })

Running your own query

For anything you cannot reach by following an id, pass a function. It receives the block's typed values and a context, and whatever it returns becomes that block's _data.

src/questpie/server/blocks/latest-news.ts
import { block } from "#questpie/factories";

export const latestNewsBlock = block("latest-news")
	.fields(({ f }) => ({
		count: f.number().default(3),
	}))
	.prefetch(async ({ values, ctx }) => {
		const res = await ctx.collections.news.find({
			limit: values.count ?? 3,
			orderBy: { publishedAt: "desc" },
		});
		return { news: res.docs };
	});

ctx is the app context with blockId, blockType and locale added, so ctx.collections, ctx.db and ctx.session are all in reach.

Both at once

Pass a loader alongside with and it runs after the expansion, with the expanded records already in hand.

.prefetch({
	with: { backgroundImage: true },
	loader: async ({ values, expanded, ctx }) => ({
		analytics: await getStats(expanded.backgroundImage?.id),
	}),
})

The two results are merged. Where a key appears in both, the loader wins.

What the renderer gets

An expanded field arrives as the whole record, or null when nothing comes back for that id, whether the row is gone or the caller may not read it.

data.backgroundImage; // { id: "asset_77", url: "", filename: "hero.jpg",} | null

The record is typed as Record<string, unknown> & { id: string }, which is why reading a specific column in a renderer needs a cast. A field holding several ids comes back as an array of the records that were found, with misses dropped.

The array case is typed as one record

For a field storing several ids the runtime writes an array, while the inferred type still says one record or null. Narrow it yourself before you map over it.

When it runs, and when it does not

Prefetch runs in the blocks field's afterRead hook, once per document, on every read that returns the column and on the document a create or an update hands back. It runs after the transaction commits, never inside it.

A read taken outside a request context, a direct database query with no app context around it, returns the stored value untouched, so _data is simply absent. Guard on it rather than assuming it.

A failed prefetch does not fail the page

If a loader throws, the error is logged, that one block's _data becomes { _error: "Prefetch failed" }, and every other block still renders.

Access

An upload field skips the asset collection's own read check and rides the parent row's. The ids are content an editor put on a row the caller could already read, so the asset comes back with it, minus any field the asset collection hides. A plain relation gets no such treatment and goes through its own collection's read rules, which is how a block can come back with a null where a private row was.

On this page