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.
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 markupKeep 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
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.
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 pushThe 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.
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
| Method | What 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.
| Operator | Value | Matches |
|---|---|---|
hasBlockType | string | Rows with a root-level block of that type. |
blockCount | { op: "gte" | "lte" | "eq"; count: number } | Rows by root-level block count. |
isEmpty / isNotEmpty | none | Rows with no, or at least one, root block. |
isNull / isNotNull | none | Rows 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.
Related
- Loading data for a block for the three
shapes of
.prefetch()and what each one costs a read. - Fields for the types and modifiers you use inside
.fields(). - Collections for the table the blocks column lives on.
- Access control for who may read the rows a prefetch pulls in.