QUESTPIE
Concepts

Blocks

A block is a reusable, field-backed content type that editors stack into a page. Declare it once with `block("hero").fields(...)` and you get typed fields, a slot in the admin block picker, optional server-side data prefetch, and a normalized tree you render on the frontend.

A block turns a record into a page. You declare a block type once with block("hero").fields(({ f }) => ({ ... })), it gets its own typed fields, a slot in the admin block picker, and optional server-side data prefetch. Add an f.blocks() field to a collection and editors can stack, reorder, and nest those blocks visually; the field stores one normalized JSONB document, and you render the saved tree on the frontend with BlockRenderer. Same field builder as collections, same codegen, no bespoke schema to maintain.

What it does

  • Defines a content type with fields, block("hero").fields(({ f }) => ({ ... })) uses the exact same f field builder as collections, so every field type, chain method, validation, and admin input is available.
  • Appears in the admin block picker, .admin(({ c }) => ({ label, icon, category })) controls how the block is labeled, grouped, ordered, and whether it shows up when an editor adds content.
  • Stores a normalized block tree, an f.blocks() field persists a BlocksDocument (_tree + _values) as one jsonb column: order, nesting, and per-block values in a single field.
  • Prefetches related data on read, .prefetch() expands relation/upload fields or runs a loader during the collection read, attaching the result to _data so the frontend renders without extra round trips.
  • Nests for layout, .allowChildren() makes a block a container that holds other blocks; the renderer hands them down as children.
  • Renders anywhere with BlockRenderer, map each block type to a React component; the renderer walks the tree, passes each block its typed values and prefetched data, and nests children.
  • Filters by block content, a blocks field ships operators like hasBlockType and blockCount, so you can query "pages that contain a cta block".

Quick start

A block is one file under your blocks/ directory. Import block from #questpie/factories (the codegen-generated factory that knows your enabled modules, so f.richText() / f.blocks() appear on f), declare admin metadata and fields, and export it.

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

export const heroBlock = block("hero")
  .admin(({ c }) => ({
    label: { en: "Hero Section", sk: "Hero sekcia" },
    icon: c.icon("ph:image"),
    category: { label: { en: "Sections" }, icon: c.icon("ph:layout"), order: 1 },
    order: 1,
  }))
  .fields(({ f }) => ({
    title: f.text(255).required().localized(),
    subtitle: f.textarea().localized(),
    backgroundImage: f.upload(),
  }))
  // Expand the upload field on read so the renderer gets a full asset record.
  // The full builder API (prefetch shapes, allowChildren, form) is below.
  .prefetch({ with: { backgroundImage: true } });

Then add an f.blocks() field to a collection to give it a page builder:

src/questpie/server/collections/pages.ts
import { collection } from "#questpie/factories";

export const pages = collection("pages")
  .fields(({ f }) => ({
    title: f.text(255).required(),
    content: f.blocks(), // visual block editor, stored as one jsonb column
  }))
  .title(({ f }) => f.title);

Then regenerate the typed surface and create the table:

questpie generate   # discovers blocks/, wires f.blocks(), builds admin.blocks + BlockProps
questpie push       # adds the jsonb content column to the pages table

That gives you a pages.content field that renders the block editor in the admin, validates each block against its declared fields, and returns a BlocksDocument your frontend can render.

Blocks require the admin module

f.blocks() and the block() factory only exist when adminModule is registered in src/questpie/server/modules.ts. The admin module registers the blocks (and richText) field types, and codegen discovers your blocks/ files and wires f.blocks() onto the generated f. Without it, f.blocks() is not on the field proxy.

Example → stored shape

A blocks field stores a normalized document: the tree (order + nesting) is kept separate from the per-block values, both keyed by a stable block id. After a read, .prefetch() results land in _data under the same ids.

// pages.content after an editor adds one hero block:
{
  _tree: [
    { id: "blk_a1", type: "hero", children: [] },
  ],
  _values: {
    blk_a1: { title: "Sharp Cuts", subtitle: "Modern barbershop", backgroundImage: "asset_77" },
  },
  // Populated by the field's afterRead hook from each block's .prefetch():
  _data: {
    blk_a1: { backgroundImage: { id: "asset_77", url: "https://.../hero.jpg" } },
  },
}

The TypeScript shape is BlocksDocument:

interface BlocksDocument {
  _tree: BlockNode[];                                  // hierarchy: order + nesting
  _values: Record<string, BlockValues>;                // blockId → field values
  _data?: Record<string, Record<string, {}>>;          // blockId → prefetched data
}

interface BlockNode {
  id: string;
  type: string;            // block type name, e.g. "hero"
  children: BlockNode[];   // nested blocks (layout blocks)
}

Structure and content are split on purpose

_tree holds structure, _values holds content, both keyed by BlockNode.id. They are separated so reordering or nesting a block never rewrites its values, and child blocks live in BlockNode.children, not inside _values. The persisted Zod schema validates { _tree, _values }; _data is added by the read hook, not stored.

The block() builder

block(name) returns a chainable builder. Every method returns a new builder, so order them top to bottom and end the chain with your export. The block type name is the first argument and must match the type stored in the tree (it's also the key under admin.blocks and the renderer lookup key).

MethodPurpose
.admin(config | (ctx) => config)Label, icon, description, category, order, and visibility in the block picker.
.fields(({ f }) => ({ ... }))The block's fields, same f proxy as collections (builtins + richText / blocks).
.form(({ f }) => ({ fields }))Lay the fields out in the editor with sections / tabs / grid.
.allowChildren(max?)Make this a layout block that can contain other blocks (optionally capped).
.prefetch(fn | { with, loader? })Fetch related data on read; the result is attached to _data[blockId].

.admin(), picker metadata

.admin() takes a config object or a function receiving { c }, the component-reference proxy (c.icon("ph:…")). The shape is AdminBlockConfig:

OptionTypeEffect
labelI18nTextDisplay name in the picker. I18nText = string | Record<locale, string>.
descriptionI18nTextTooltip / help text.
iconComponentReferenceIcon, e.g. c.icon("ph:image").
categoryBlockCategoryConfigGroups the block: { label: I18nText; icon?; order? }.
ordernumberOrder within its category (lower = first).
hiddenbooleanHide from the picker.
.admin(({ c }) => ({
  label: { en: "Hero Section", sk: "Hero sekcia" },
  icon: c.icon("ph:image"),
  category: { label: { en: "Sections" }, icon: c.icon("ph:layout"), order: 1 },
  order: 1,
}))

Reuse one category across blocks

Export a helper that returns a BlockCategoryConfig so every block in a group shares one label/icon/order. Type it with AdminConfigContext and BlockCategoryConfig from @questpie/admin/factories:

src/questpie/server/blocks/_categories.ts
import type { AdminConfigContext, BlockCategoryConfig } from "@questpie/admin/factories";

export const sections = (c: AdminConfigContext["c"]): BlockCategoryConfig => ({
  label: { en: "Sections", sk: "Sekcie" },
  icon: c.icon("ph:layout"),
  order: 1,
});

Then category: sections(c) in each block's .admin().

.fields(), the block's data

.fields() is the same callback as collections and globals, ({ f }) => ({ ... }), so every field type and chain method is available, including .required(), .localized(), .default(), .label(), relations, and uploads. Each field's per-field admin config (e.g. reactive hidden) works exactly as it does on a collection.

.fields(({ f }) => ({
  title: f.text(255).required().localized(),
  count: f.number().default(3),
  showFeatured: f.boolean().default(true),
  layout: f.select([
    { value: "grid", label: "Grid" },
    { value: "list", label: "List" },
  ]).default("grid"),
}))

Field values are validated against the block's generated Zod schema and stored under _values[blockId]. The field's read type flows into the renderer's values prop (see TypeScript).

.form(), editor layout

.form() arranges the block's fields into sections, tabs, or a grid, identical to a collection's form layout. You reference fields by name through the f proxy (here f is a name proxy, not the field builder).

.form(({ f }) => ({
  fields: [
    { type: "section", label: "Basic", fields: [f.title, f.subtitle] },
    { type: "section", label: "Pricing", layout: "grid", fields: [f.price, f.currency] },
  ],
}))

.allowChildren(), layout blocks

Call .allowChildren() to let a block contain other blocks, that is what BlockNode.children is for. Pass a number to cap the count.

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

export const columnsBlock = block("columns")
  .admin(({ c }) => ({ label: "Columns", icon: c.icon("ph:columns") }))
  .allowChildren()        // or .allowChildren(4) for at most 4 children
  .fields(({ f }) => ({
    columns: f.select([
      { value: "2", label: "2" },
      { value: "3", label: "3" },
    ]).default("2"),
  }));

Children render via the children prop your renderer receives (see Rendering).

Prefetching block data

A block often needs related records the editor only referenced by id (an upload, a relation), or computed data (a list of recent posts). .prefetch() runs during the collection read, batched across every block on the page, and its result is attached to _data[blockId], so your renderer reads data.* with no client fetch. There are three shapes.

Shape 1, expand relation/upload fields (with)

Declare which relation or upload fields to expand. They are batch-fetched across every block on the page and replaced with full records.

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

// Nested expansion is passed straight through to the collection's find `with`:
.prefetch({ with: { author: { with: { avatar: true } } } })

Each expanded field becomes ExpandedRecord | null under _data[blockId][field] (an array of records for .multiple() relations). Only fields whose metadata is a relation are expanded, a non-relation field name in with is silently ignored, never partially resolved.

Shape 2, a custom loader function

For computed data, pass a function. It receives the block's typed values and a BlockPrefetchContext (ctx), and returns arbitrary data merged into _data[blockId].

.prefetch(async ({ values, ctx }) => {
  const res = await ctx.collections.news.find({
    limit: values.count ?? 3,
    orderBy: { publishedAt: "desc" },
  });
  return { news: res.docs };
})

ctx is an AppContext plus { blockId, blockType, locale? }, so ctx.collections.<name>.find(...), ctx.db, and ctx.session are available.

Shape 3, expand, then run a loader

Combine the two: expand fields with with, then run a loader that receives the expanded records plus values.

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

On key conflicts, loader/function data wins over expanded data (it is merged on top).

Prefetch runs in afterRead, keep it outside an open transaction

Prefetch runs in the blocks field's afterRead hook. Heavy reads inside an already-open transaction can deadlock Bun SQL, because the context-inherited tx connection blocks while you query. Keep block hydration (and any field afterRead) outside an open tx. See the framework invariant in CLAUDE.md (feedback_save_inside_tx).

Prefetch is best-effort and access-aware

A failing loader never throws into your read: that block's _data is set to { _error: "Prefetch failed" } and the rest of the page still renders. The blocks field's afterRead also returns the raw value untouched when called outside request scope (e.g. a direct DB read with no context). For with expansion, upload fields inherit the parent row's read decision (the asset ids are editor-curated content on a row the caller could already read), while plain relations keep their target collection's normal read access.

Rendering on the frontend

Blocks are stored, not rendered, on the server, you own the markup. Pair each block type with a React component, then hand the saved document to BlockRenderer from @questpie/admin/client. Codegen collects your per-block renderers into admin.blocks, the map you pass as renderers.

src/components/PageRenderer.tsx
import admin from "@/questpie/admin/.generated/client";
import { BlockRenderer, type BlockContent } from "@questpie/admin/client";

export function PageRenderer({ page }: { page: { content: BlockContent } }) {
  return (
    <BlockRenderer
      content={page.content}                 // _tree + _values
      renderers={admin.blocks}               // block type component
      data={page.content._data}              // prefetched data, keyed by block id
    />
  );
}

BlockRenderer walks content._tree, looks up each node's renderer by type (falling back from kebab-case to camelCase), and renders nothing for an unknown type (with a dev-only console warning). Its props (BlockRendererComponentProps):

PropTypePurpose
contentBlockContentThe stored { _tree, _values, _data? }.
renderersRecord<string, (props) => ReactNode>Block type → component, e.g. the generated admin.blocks.
dataRecord<string, unknown>Prefetched _data, keyed by block id (optional; for SSR).
classNamestringContainer class.
selectedBlockId, onBlockClick, onBlockInsertnoneEditor / live-preview wiring (select + insert affordances).

Each renderer receives the block's id, typed values, prefetched data, and rendered children:

src/questpie/admin/blocks/hero.tsx
import type { BlockProps } from "../.generated/client";

export function HeroRenderer({ values, data, children }: BlockProps<"hero">) {
  const bgUrl = data?.backgroundImage?.url as string | undefined;
  return (
    <section style={{ backgroundImage: bgUrl ? `url(${bgUrl})` : undefined }}>
      <h1>{values.title}</h1>
      {values.subtitle && <p>{values.subtitle}</p>}
      {children}              {/* nested blocks, for layout blocks */}
    </section>
  );
}

values is typed from the block's .fields(); data is typed from its .prefetch(). Layout blocks (.allowChildren()) render their nested blocks through children. Sources: packages/admin/src/client/blocks/block-renderer.tsx, examples/tanstack-barbershop/src/components/pages/PageRenderer.tsx, examples/tanstack-barbershop/src/questpie/admin/blocks/hero.tsx.

What `BlockRenderer` actually passes each renderer

At runtime, BlockRenderer passes each renderer component id, type (the block type name, an extra prop the typed shape below omits), values, data, and the rendered children, and nothing else. The isSelected / isPreview fields in the renderer-props type are reserved and not wired by BlockRenderer: they are always undefined here, so don't build rendering logic on them (selection state lives on the editor wrapper, not the renderer). Stick to values, data, and children.

Same renderers power live preview

Wrap fields in PreviewField to make them inline-editable in live preview, and forward selectedBlockId / onBlockClick / onBlockInsert from useCollectionPreview(), BlockRenderer then adds click-to-select and insert affordances in preview mode while rendering the exact same components in production.

Querying blocks fields

The blocks field ships custom operators so you can filter on block content, not just presence. They are available on the where clause for a blocks field.

OperatorValueMatches
hasBlockTypestringDocuments whose tree contains a block of that type.
blockCount{ op: "gte" | "lte" | "eq"; count: number }Documents by root-level block count.
isEmpty / isNotEmptynoneDocuments with no / at least one root block.
isNull / isNotNullnoneDocuments where the field is / isn't null.
// Pages that contain a CTA block:
await app.collections.pages.find({
  where: { content: { hasBlockType: "cta" } },
});

// Pages with at least 3 root-level blocks:
await app.collections.pages.find({
  where: { content: { blockCount: { op: "gte", count: 3 } } },
});

hasBlockType and blockCount inspect _tree only (root level for the count), via Postgres JSONB functions (jsonb_array_elements, jsonb_array_length).

TypeScript

The block's field values and prefetch data drive the renderer types, you rarely write them by hand. In a renderer, use the generated BlockProps<"name">:

import type { BlockProps } from "@/questpie/admin/.generated/client";

type HeroProps = BlockProps<"hero">;
// { id; values; data?; children?; isSelected?; isPreview? }
// values inferred from .fields(), data inferred from .prefetch()

BlockProps<T> resolves to BlockRendererProps<InferBlockValues<...>, InferBlockData<...>> (the per-renderer props type, distinct from the <BlockRenderer> component's BlockRendererComponentProps), so values comes from the block's .fields() and data from its .prefetch(). The renderer props themselves are { id, values, data?, children?, isSelected?, isPreview? }, destructure what you need (values, data, and children are the ones BlockRenderer populates; isSelected / isPreview are reserved and unwired, see the Rendering note). The document and node types come from @questpie/admin/fields:

import type { BlocksDocument, BlockNode } from "@questpie/admin/fields";

If you build blocks programmatically (a plugin), block, BlockBuilder, AdminBlockConfig, BlockCategoryConfig, AdminConfigContext, BlockPrefetchContext, and the inference helpers InferBlockValues / InferBlockData are exported from @questpie/admin/factories (and @questpie/admin/server); BlockRenderer, BlockRendererProps, BlockContent, BlockNode, and isBlockContent from @questpie/admin/client. Sources: packages/admin/src/exports/{server,factories,fields,client}.ts.

Import `block` from `#questpie/factories`, not `@questpie/admin/server`

In app code, always import block from #questpie/factories. The generated factory wraps the field proxy with your enabled modules so f.richText() / f.blocks() are available inside a block's .fields(); the bare block() from @questpie/admin/server only sees builtin fields.

  • Fields, every field type and chain method you use inside block().fields(), plus f.blocks() in the field reference and how to build your own field type.
  • Collections, where the f.blocks() field lives and how the document is read and stored.
  • Relations, what .prefetch({ with }) expands, and the with hydration it reuses.
  • Hooks, the afterRead lifecycle that prefetch runs in (and the open-transaction caveat).
  • Access control, the read rules that apply to expanded relations (and the upload-inherits-parent-row exception).
  • Example: examples/tanstack-barbershop/src/questpie/server/blocks/ paired with src/questpie/admin/blocks/, a full page builder, server blocks paired with frontend renderers. examples/city-portal/src/questpie/server/blocks/ shows shared categories, allowChildren, and a loader prefetch.

On this page