QUESTPIE
AdminConfiguration

Dashboard

Nine widget types under the dashboard key of config/admin.ts. Four read a collection for you. Three need a server loader you write.

View markdown

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

typeShowsWhere the data comes from
statsOne count, bigCounts a collection
chartLine, bar, area or pieGroups a collection by one field
recentItemsThe latest rows, as a listReads a collection, newest first
tableA small table with chosen columnsReads a collection
quickActionsButtonsNothing to fetch
valueOne metric, with an optional trendA loader you write
progressA bar against a targetA loader you write
timelineAn event streamA loader you write
customYour own React componentYour component, or a loader

The shape

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

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.

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.

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.

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.

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.

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.

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.

{
	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 is the other half of how the panel looks. Colours, fonts, dark mode, and the three components you can replace outright.

On this page