QUESTPIE
Schema

Blocks

A block is a content type an editor stacks into a page. Declare its fields once and the admin gets a picker, the row gets one jsonb column, and your app gets a tree to render.

View markdown

Someone who is not you has to reorder the homepage on a Tuesday. This page starts from one block file and ends with that block on the screen, rendered by your own component.

Two files per block

A block is a pair. The fields live on the server, the markup lives beside your admin, and the generator pairs the two by filename.

questpie add block hero
# src/questpie/server/blocks/hero.ts    the fields
# src/questpie/admin/blocks/hero.tsx    the markup

Keep both basenames the same, and name the server export after the file. The renderer gets its types by importing heroBlock from hero.ts under that exact name.

Declaring the fields

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

export const heroBlock = block("hero")
	.admin(({ c }) => ({
		label: "Hero",
		icon: c.icon("ph:image"),
		category: { label: "Sections", order: 1 },
		order: 1,
	}))
	.fields(({ f }) => ({
		title: f.text(255).required(),
		subtitle: f.textarea(),
		backgroundImage: f.upload(),
	}))
	.prefetch({ with: { backgroundImage: true } });

.fields() is the callback a collection takes, with the same f proxy and the same chain methods, over the built-in field types plus the two @questpie/admin adds. The argument to block() is the type name stored in the tree and the key your renderer is found by, so let it match the filename.

Giving a collection a page builder

f.blocks() takes no arguments and adds one jsonb column named after the field.

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(),
	}))
	.title(({ f }) => f.title);
questpie generate
questpie push

The form for pages now shows a canvas where an editor picks blocks out of your categories, drags them into order, and nests them.

Blocks come from `@questpie/admin`

block() and f.blocks() exist only when adminModule is registered in src/questpie/server/modules.ts. The module contributes both, and the generator puts them on #questpie/factories. Import block from there, never from the package.

What the column holds

One document, normalized. Structure and content are separate, keyed by the same block id.

// pages.content, after an editor drops in one hero
{
	_tree: [{ id: "blk_a1", type: "hero", children: [] }],
	_values: {
		blk_a1: { title: "Sharp Cuts", backgroundImage: "asset_77" },
	},
	_data: {
		blk_a1: { backgroundImage: { id: "asset_77", url: "https://…/hero.jpg" } },
	},
}

_tree and _values are what the column stores and what the write schema checks. _data is not stored. It is attached on every read from what each block prefetched. Moving a block rewrites _tree and leaves its values untouched. Nesting lives in _tree alone: a child sits in its parent's children, and _values stays flat, one entry per block at any depth.

Rendering it

The markup is yours. QUESTPIE hands each renderer the block's values and its prefetched data, both typed from the block file.

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

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

The generator collects that folder into admin.blocks. Hand the map and the saved document to BlockRenderer and the page is on screen.

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

<BlockRenderer
	content={page.content}
	renderers={admin.blocks}
	data={page.content._data}
/>;

Rendering a page covers the lookup rule, every prop a renderer receives, and the live-preview wiring.

The rest of the builder

MethodWhat it does
.admin(config | (ctx) => config)Label, description, icon, category, order and hidden.
.fields(({ f }) => ({ … }))The block's own fields.
.form(({ f }) => ({ fields }))Groups those fields into sections, tabs or a grid in the editor.
.allowChildren()Lets this block hold others, handed to the renderer as children.
.prefetch(…)Loads data for the block on read.

Blocks that hold other blocks

.allowChildren() makes a block a container. The editor lets one block be dropped inside another, the nesting is kept in BlockNode.children, and the renderer receives those children already rendered.

Naming it and laying it out

The block picker covers every .admin() option, how categories are grouped and sorted, how to share one category across a folder of blocks, and what .form() does to the editing panel.

Filtering by block content

A blocks column carries its own operators, so a where clause can ask what is on the page.

OperatorValueMatches
hasBlockTypestringRows with a root-level block of that type.
blockCount{ op: "gte" | "lte" | "eq"; count: number }Rows by root-level block count.
isEmpty / isNotEmptynoneRows with no, or at least one, root block.
isNull / isNotNullnoneRows where the column is or is not null.
const { docs } = await app.collections.pages.find({
	where: { content: { hasBlockType: "cta" } },
});

hasBlockType and blockCount read the root of _tree only. A cta nested inside a columns block matches neither.

On this page