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.
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 file | Client file | What it is |
|---|---|---|
server/views/*.ts | admin/views/*.tsx | A list or form screen |
server/components/*.ts | admin/components/*.tsx | A component you name from config |
server/blocks/*.ts | admin/blocks/*.tsx | A block for the block editor |
| none | admin/pages/*.tsx | A screen at its own URL |
| none | admin/widgets/*.tsx | A 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.
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.
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.
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
| Prop | What it holds |
|---|---|
collection | The registered name. A global form view gets global instead |
viewConfig | Your .list() or .form() config, after introspection |
config | The collection's admin meta. No list or form config is on it |
navigate(path) | Moves the admin router |
basePath | /admin in the starters |
id | Collection 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
| Kind | Route | Named from |
|---|---|---|
list | /admin/collections/:name | .list(), through v |
form | That path plus /create or /:id, and /admin/globals/:name | .form(), through v |
document | The collection create and edit routes | Nothing. 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.
import { component } from "@questpie/admin/factories";
export default component<{ text: string; tone?: "ok" | "warn" }>("status-pill");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
| Topic | Page |
|---|---|
| A screen at its own URL | Custom pages |
| A tile on the dashboard | Dashboard widgets |
| Stacked content an editor arranges | Blocks |
| Sidebar, dashboard layout, branding | Configuration |
.admin(), .list(), .form(), actions | Collections and globals |
Next
Custom pages is the extension with no server half. One file, one URL, no collection involved.