QUESTPIE
Admin

Custom Views and Components

Every admin screen is resolved by name. Declare a name on the server, put a React file under that name beside the admin, and your screen mounts where the generated one was.

View markdown

The generated list and form fit most tables. Some do not. A board of cards, a wizard, a screen that is not a collection at all. You write those, and the admin mounts them like its own.

Two files, one name

An extension is a pair, or client only. The server file declares the name. The client file supplies the React. Codegen matches the two.

questpie add view board
# src/questpie/server/views/board.ts   the name and the kind
# src/questpie/admin/views/board.tsx   the component
Server fileClient fileWhat it is
server/views/*.tsadmin/views/*.tsxA list or form screen
server/components/*.tsadmin/components/*.tsxA component you name from config
server/blocks/*.tsadmin/blocks/*.tsxA block for the block editor
noneadmin/pages/*.tsxA screen at its own URL
noneadmin/widgets/*.tsxA tile on the dashboard

Both roots sit under src/questpie/. A fresh app has none of these directories.

A board instead of a table

Declare the view. kind decides which route serves it.

src/questpie/server/views/board.ts
import { view } from "@questpie/admin/factories";

export const boardView = view("board", { kind: "list" });

Point a collection at it. v carries one method per registered list view.

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

export const tasks = collection("tasks")
	.fields(({ f }) => ({
		title: f.text(160).label("Title").required(),
		status: f
			.select([
				{ value: "todo", label: "To do" },
				{ value: "doing", label: "Doing" },
				{ value: "done", label: "Done" },
			])
			.label("Status")
			.default("todo"),
	}))
	.title(({ f }) => f.title)
	.list(({ v, f }) =>
		v.board({
			columns: [f.title, f.status],
			grouping: { fields: [f.status], defaultField: f.status },
		}),
	);

Now the renderer. It reads the same config back off viewConfig.

src/questpie/admin/views/board.tsx
import {
	AdminViewHeader,
	AdminViewLayout,
	type CollectionListViewProps,
	useCollectionList,
	view,
} from "@questpie/admin/client";

function BoardView({ collection, viewConfig }: CollectionListViewProps) {
	const groupBy = viewConfig?.grouping?.defaultField ?? "status";
	const { data } = useCollectionList(collection, { limit: 100 });
	const docs: any[] = data?.docs ?? [];
	const lanes = [...new Set(docs.map((doc) => String(doc[groupBy])))];

	return (
		<AdminViewLayout header={<AdminViewHeader title="Board" />}>
			<div className="flex gap-4 overflow-x-auto">
				{lanes.map((lane) => (
					<section key={lane} className="w-64 shrink-0">
						<h2 className="mb-2 text-sm font-medium">{lane}</h2>
						{docs
							.filter((doc) => String(doc[groupBy]) === lane)
							.map((doc) => (
								<article key={doc.id} className="mb-2 rounded-md border p-2">
									{doc._title}
								</article>
							))}
					</section>
				))}
			</div>
		</AdminViewLayout>
	);
}

export default view("board", { kind: "list", component: BoardView });

Run questpie generate. /admin/collections/tasks is a board.

What the view receives

PropWhat it holds
collectionThe registered name. A global form view gets global instead
viewConfigYour .list() or .form() config, after introspection
configThe collection's admin meta. No list or form config is on it
navigate(path)Moves the admin router
basePath/admin in the starters
idCollection form views only. Undefined on the create route

Introspection ships a fixed set of keys

.list() reaches the view through a whitelist: columns, defaultSort, defaultFilters, quickFilters, orderable, searchable, filterable, grouping, layout, outline, actions. Keys of your own are dropped in transit. .form() keeps fields and sidebar.

The three kinds

KindRouteNamed from
list/admin/collections/:name.list(), through v
formThat path plus /create or /:id, and /admin/globals/:name.form(), through v
documentThe collection create and edit routesNothing. See below

document is the Notion-style screen shipped as collection-document. No server file registers it, and .form() offers only form views, so no typed call selects it. Mount DocumentView from @questpie/admin/client yourself.

Naming a component from config

A component reference is a name plus props. The server puts one in config. The client renders the React component registered under that name.

src/questpie/server/components/status-pill.ts
import { component } from "@questpie/admin/factories";

export default component<{ text: string; tone?: "ok" | "warn" }>("status-pill");
src/questpie/admin/components/status-pill.tsx
type Props = { text: string; tone?: "ok" | "warn" };

export default function StatusPill({ text, tone }: Props) {
	return (
		<span className={tone === "warn" ? "text-warning" : "text-success"}>
			{text}
		</span>
	);
}

c.statusPill({ text: "Priority", tone: "warn" }) now returns a reference to it. c reaches you in .admin(), .list() and .actions(). Everywhere else write the shape by hand: { type: "statusPill", props: { text: "Priority" } }.

The two halves are keyed differently. The server key is the factory string, with kebab-case turned to camelCase. The client key is the camelCased file name. Name both files after the string and the two always line up.

Rules

Server files stay serializable. Return references, names, strings, numbers, arrays and objects. React lives under src/questpie/admin/.

On the server the factory string is the identity, not the file name. So views/board.ts may hold view("board-view"), and it reads as v.boardView() in config. The client is stricter. Name that file board-view.tsx and pass the same string, because codegen pairs the halves by file name and the router resolves the view by string.

A server view, component or block with no client file fails questpie generate. The error names the file to create. The check runs one way only, so a client-only component is fine. questpie dev reruns codegen as you edit.

Where each topic lives

TopicPage
A screen at its own URLCustom pages
A tile on the dashboardDashboard widgets
Stacked content an editor arrangesBlocks
Sidebar, dashboard layout, brandingConfiguration
.admin(), .list(), .form(), actionsCollections and globals

Next

Custom pages is the extension with no server half. One file, one URL, no collection involved.

On this page