# Custom Views and Components (/docs/admin/custom-views)

---
title: Custom Views and Components
description: 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.
kind: guide
package: "@questpie/admin"
---

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.

```bash
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.

```ts title="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.

```ts title="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`.

```tsx title="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

| 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     |

<Callout type="warn" title="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`.
</Callout>

## 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.

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

export default component<{ text: string; tone?: "ok" | "warn" }>("status-pill");
```

```tsx title="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

| Topic                                     | Page                                                  |
| ----------------------------------------- | ----------------------------------------------------- |
| A screen at its own URL                   | [Custom pages](/docs/admin/custom-views/pages)        |
| A tile on the dashboard                   | [Dashboard widgets](/docs/admin/custom-views/widgets) |
| Stacked content an editor arranges        | [Blocks](/docs/schema/blocks)                         |
| Sidebar, dashboard layout, branding       | [Configuration](/docs/admin/configuration)            |
| `.admin()`, `.list()`, `.form()`, actions | [Collections and globals](/docs/admin/collections)    |

## Next

**[Custom pages](/docs/admin/custom-views/pages)** is the extension with no
server half. One file, one URL, no collection involved.
