# Dashboard (/docs/admin/configuration/dashboard)

---
title: Dashboard
description: Nine widget types under the dashboard key of config/admin.ts. Four read a collection for you. Three need a server loader you write.
kind: reference
package: "@questpie/admin"
---

Write no `dashboard` key and `/admin` shows a welcome card. Write one and the
panel becomes the numbers your team asks you for.

## The nine types

| `type`         | Shows                              | Where the data comes from        |
| -------------- | ---------------------------------- | -------------------------------- |
| `stats`        | One count, big                     | Counts a collection              |
| `chart`        | Line, bar, area or pie             | Groups a collection by one field |
| `recentItems`  | The latest rows, as a list         | Reads a collection, newest first |
| `table`        | A small table with chosen columns  | Reads a collection               |
| `quickActions` | Buttons                            | Nothing to fetch                 |
| `value`        | One metric, with an optional trend | A loader you write               |
| `progress`     | A bar against a target             | A loader you write               |
| `timeline`     | An event stream                    | A loader you write               |
| `custom`       | Your own React component           | Your component, or a loader      |

## The shape

`dashboard` takes a title, header actions, and a flat list of items.

```ts title="src/questpie/server/config/admin.ts"
import { adminConfig } from "#questpie/factories";

export default adminConfig({
	dashboard: {
		title: "Operations",
		description: "Content and workflow",
		columns: 4,
		actions: [
			{
				id: "new-post",
				label: "New post",
				href: "/admin/collections/posts/create",
				icon: { type: "icon", props: { name: "ph:plus" } },
				variant: "primary",
			},
		],
		items: [
			{
				id: "published",
				type: "stats",
				collection: "posts",
				label: "Published",
				filter: { published: true },
				span: 1,
			},
			{
				id: "recent",
				type: "recentItems",
				collection: "posts",
				label: "Recent posts",
				dateField: "updatedAt",
				limit: 6,
				span: 2,
			},
		],
	},
});
```

Header actions are plain links. `id`, `label` and `href` are required. `variant`
takes `default`, `primary`, `secondary`, `outline` or `ghost`.

## Loaders

Three types cannot count for themselves. `value`, `progress` and `timeline` each
take a `loader`. It is an async function and it runs on the server. It never
reaches the browser. The config route strips it out and sends `hasLoader: true`
in its place. The widget then asks the server for its own data, by id.

```ts title="src/questpie/server/config/admin.ts"
import { adminConfig } from "#questpie/factories";
import type { WidgetFetchContext } from "@questpie/admin/factories";

export default adminConfig({
	dashboard: {
		items: [
			{
				id: "draft-count",
				type: "value",
				label: "Drafts waiting",
				loader: async ({ collections }: WidgetFetchContext) => {
					const count = await collections.posts.count({
						where: { status: "draft" },
					});
					return { value: count };
				},
			},
		],
	},
});
```

The context is the app context plus `db`, `collections` and `globals`. So a
loader can do anything a route handler can do, including joining two
collections or reading a global.

<Callout type="warn" title="A widget with a loader needs an `id` you wrote">
	The server finds the loader by widget id. Auto-assigned ids do not survive
	that lookup for items written in the `sectionId` style, and the widget answers
	with a not-found error. Give every loader-backed widget an explicit `id`.
</Callout>

`stats`, `chart`, `recentItems`, `table` and `custom` also accept a `loader`. It
replaces the built-in query, so match the shape it replaced. `stats` wants
`{ count }`. `chart` wants `{ name, value }[]`.

## Options per type

Every widget takes `id`, `label`, `span`, `rowSpan` and `access`. These are the
rest.

### stats

`collection` names what to count. `filter` becomes the `where` clause. `icon`
takes a component reference.

### chart

`chartType` is `line`, `bar`, `area` or `pie`. `collection` and `field` say what
to group. `timeRange` is `7d`, `30d`, `90d` or `1y`, and only bites when the
field holds dates. Without a loader the widget reads up to 1000 rows and groups
them in the browser. Point it at a field with few distinct values.

### recentItems

`collection` and `dateField` decide what to list and what to sort on, newest
first. `dateField` falls back to `createdAt`. `limit` caps the rows and defaults
to 5. Use a `loader` when the list needs filtering.

### table

`collection` and `columns` are required. `columns` takes plain field names, or
`{ key, label }` when the header should read differently. `limit` defaults to 5.
`sortBy` with `sortOrder` orders the rows, and `filter` becomes the `where`.

### quickActions

`actions` is a list of `{ label, icon, variant, action }`. The action is one of
`{ type: "create", collection }`, `{ type: "link", href, external }` or
`{ type: "page", pageId }`. `layout` is `grid` or `list`.

### value

`loader` is required and returns `{ value }` plus any of `formatted`,
`subtitle`, `footer`, `icon` and `trend`. `trend` is `{ value, icon }`, so the
percentage and its arrow are yours to compute. `cardVariant` is `default`,
`compact` or `featured`.

### progress

`loader` is required and returns `{ current, target }` plus optional `label` and
`subtitle`. `showPercentage` is on already. `color` sets the bar.

### timeline

`loader` is required and returns rows of `{ id, title, timestamp }`. A row also
takes `description`, `icon`, `href` and a `variant`. The variants are `default`,
`success`, `warning`, `error` and `info`. `maxItems` caps the list at 10 unless
you say otherwise. `timestampFormat` is `relative`, `absolute` or `datetime`.

### custom

`widgetType` names a widget you registered in `src/questpie/admin/widgets/`.
Your component reads `props` off its `config` prop. Add a `loader` when the
props are not enough. See [Custom views](/docs/admin/custom-views).

## Layout

`columns` sets the grid, 1 to 12, and defaults to 4. A widget's `span` is how
many of those columns it takes, and defaults to 1. `rowSpan` is how many rows,
and clamps between 1 and 8.

`rowSpan` defaults by type. `stats`, `value` and `progress` take one row.
Everything else takes two. `rowHeight` decides what a row is worth, `8.5rem` if
you say nothing, and `gap` counts in quarter-rem steps.

A grid of three columns or more collapses as its container narrows, down to a
single column on a phone. The one- and two-column presets stay as written.

### Sections and tabs

An item with `type: "section"` groups widgets under a heading and can set its
own `columns`. `wrapper` is `flat`, `card` or `collapsible`, and a collapsible
section takes `defaultCollapsed`.

An item with `type: "tabs"` holds `tabs`, each with an `id`, a `label` and its
own `items`. `defaultTab` picks the one that opens.

There is a second way to write the same thing. Give an item a `sectionId` and it
joins that section from the flat list, the way sidebar items do. That form is
also how a module adds a widget to a dashboard you own.

<Callout type="info" title="Two shapes, two levels of type checking">
	An item carrying a `sectionId` is checked loosely, so extra keys pass. An item
	nested in a section must match its widget type exactly. That is why the same
	widget sometimes wants `dateField` and sometimes does not.
</Callout>

## Who sees what

`access` on a widget is a boolean, or a function. The function is handed `app`,
`db`, `session` and `locale`. Return false and the widget never reaches that
person's browser. It is dropped from the config, not hidden with CSS.

```ts
{
	id: "revenue",
	type: "value",
	access: ({ session }) => session?.user?.role === "owner",
	loader: async ({ collections }) => ({
		value: await collections.orders.count(),
	}),
}
```

Widgets bound to a collection are filtered too. A session that cannot read
`posts` gets no `posts` widget. A `quickActions` button that creates a `posts`
row is dropped from the list it sits in.

## Staying current

`refreshInterval` on a widget is milliseconds, and refetches loader-backed data
on that interval. `realtime: true` on the dashboard makes collection-backed
widgets follow live updates, and a widget can override it.

## Next

**[Theming](/docs/admin/configuration/theming)** is the other half of how the
panel looks. Colours, fonts, dark mode, and the three components you can
replace outright.
