| name | questpie-admin |
| description | QUESTPIE admin panel, setup, branding, theming, sidebar, dashboard, views, blocks, custom fields, media, localization, realtime refresh and locks, live preview, auth, dark mode, CSS variables. Use when building or customizing the QUESTPIE admin UI. |
| license | MIT |
| metadata | {"author":"questpie","version":"3.0.0"} |
QUESTPIE Admin Panel
The QUESTPIE admin panel is a projection of your server schema, not the framework itself. It reads collections, globals, and config via introspection and generates a full admin interface. Your backend works without it.
Reference Topics
| Topic | File | Covers |
|---|
| Views | references/views.md | List/form/dashboard views, realtime refresh and locks, sidebar, filters, bulk actions, history |
| Blocks | references/blocks.md | Block definitions, fields, prefetch, renderers, block picker |
| Custom UI | references/custom-ui.md | Declarative field()/view()/widget()/page() definitions, component props, reactive fields |
| Recipes | references/recipes.md | BE vs FE field, end-to-end custom field, custom admin page (e.g. chat), custom view |
Full Compiled Document
AGENTS.md expands this file plus every references/ topic inline, in one document. It is generated (bun run scripts/build-skill-docs.ts), so read it when you want everything at once, but edit the sources (this file and references/), never AGENTS.md directly.
Tech Stack
- React + Tailwind CSS v4 + shadcn components
- @base-ui/react primitives (NOT @radix-ui)
- @iconify/react with Phosphor icon set (
ph:icon-name)
- sonner for toasts,
toast.error(), toast.success()
- QUESTPIE Neutral Design: flat surfaces, soft neutral geometry,
tokenized radius, and restrained floating shadows
Setup
1. Install
bun add @questpie/admin
2. Runtime Config
import { runtimeConfig } from "questpie/app";
export default runtimeConfig({
app: { url: process.env.APP_URL || "http://localhost:3000" },
db: { url: process.env.DATABASE_URL },
secret: process.env.APP_SECRET,
});
The admin module contributes the codegen plugin automatically. It discovers config/admin.ts, blocks/, views, components, and admin client modules.
3. Modules
import { adminModule } from "@questpie/admin/modules/admin";
import { auditModule } from "@questpie/admin/modules/audit";
export default [adminModule, auditModule] as const;
| Module | Provides |
|---|
adminModule | User collection, auth pages, admin UI |
auditModule | Audit log collection, timeline widget |
Auth/User Contract - Critical
adminModule includes the starter auth model and owns the canonical Better Auth user collection shape used by admin setup and login guards. That contract includes user.role with at least admin and user values. The built-in setup route checks for role = "admin", and the admin AuthGuard expects session.user.role === "admin".
Do not create a replacement collection("user") from scratch in an app that uses adminModule. If the app needs to customize the user admin UI or add fields, merge the starter user collection and extend it:
import { starterModule } from "questpie/app";
import { collection } from "#questpie/factories";
export default collection("user")
.merge(starterModule.collections.user)
.fields(({ f }) => ({
internalNotes: f.textarea(),
}));
App-specific authorization tables are fine, but they must not replace or remove the admin user contract unless the app also replaces the built-in admin setup route and auth guard.
4. Admin Config
import { adminConfig } from "#questpie/factories";
export default adminConfig({
branding: {
name: { en: "My App Admin" },
},
sidebar: {
sections: [
{ id: "overview", title: { en: "Overview" } },
{ id: "content", title: { en: "Content" } },
],
items: [
{
sectionId: "overview",
type: "link",
label: { en: "Dashboard" },
href: "/admin",
icon: { type: "icon", props: { name: "ph:house" } },
},
{
sectionId: "content",
type: "collection",
collection: "posts",
},
],
},
});
5. Codegen
bunx questpie generate
6. Mount the Admin
import { AdminRouter } from "@questpie/admin/client";
import { admin } from "@/questpie/admin/admin";
export default function AdminPage() {
return <AdminRouter admin={admin} />;
}
The admin client config is auto-generated by codegen at admin/.generated/client.ts. No manual builder setup needed.
Branding
import { adminConfig } from "#questpie/factories";
export default adminConfig({
branding: {
name: { en: "Barbershop Control", sk: "Riadenie barbershopu" },
},
});
| Option | Type | Description |
|---|
name | string | i18n | App name shown in sidebar header |
logo | string | Logo URL or path |
Theming (CSS Variables)
The admin uses CSS variables for all theming. Override them in your own CSS file.
The full source of truth is packages/admin/DESIGN.md.
Theme Mode (light / dark / system)
AdminLayoutProvider (and AdminRouter) manage the theme themselves: they
toggle the .dark / .light class on <html> and set color-scheme.
Default is "system" (follows the OS), the user's choice persists in
localStorage (questpie:admin-theme), and a toggle is shown in the sidebar
user footer (hide it with showThemeToggle={false}).
<AdminLayoutProvider theme="dark" ... />
<AdminLayoutProvider theme={theme} setTheme={setTheme} ... />
CRITICAL: never wrap the admin in <div className="dark">. Floating UI
(dialogs, popovers, selects, toasts) renders through portals into
document.body (outside your wrapper), so it follows the <html> class
instead. Result: mixed light/dark surfaces and a theme toggle that appears
broken. If you want dark always, pass theme="dark"; the provider applies it
at the root where portals can see it.
Token structure in base.css: dark values live on :root, .dark (dark is
the default), light values on .light, :root.light. Mirror those selectors
when overriding (see Custom Theme below).
Key Defaults
| Role | Dark | Light |
|---|
| Background | #121212 | #fafafa |
| Foreground | #ececec | #1c1c1c |
| Card | #1b1b1b | #ffffff |
| Surface high | #2a2a2a | #e8e8e8 |
| Border | #343434 | #e2e2e2 |
| Border subtle | #262626 | #ebebeb |
| Brand primary | #b700ff | #b700ff |
| Token | Default | Use |
|---|
--control-radius-inner | 8px | Icon buttons/actions nested inside controls |
--control-radius | 12px | Inputs, selects, buttons, compact controls |
--surface-radius | 14px | Cards, panels, grouped fields, docs blocks |
--floating-radius | 14px | Menus, popovers, dialogs, command panels |
Typography
| Variable | Value |
|---|
--font-sans | "Geist Variable", UI, prose, headings, labels, navigation |
--font-mono | "JetBrains Mono Variable", code, file paths, commands, IDs |
Sidebar Variables
Separate tokens for independent sidebar theming: --sidebar, --sidebar-foreground, --sidebar-primary, --sidebar-accent, --sidebar-border, --sidebar-ring.
Custom Theme (Brand Overrides)
Don't copy or fork the admin stylesheet. Import it, then override tokens
after the import for zero component changes. Mirror base.css selectors
exactly (:root, .dark for dark, .light, :root.light for light): base.css
uses :root.light (specificity 0-2-0) for light tokens, so a plain .light
or :root override silently loses in light mode no matter the import order.
At equal specificity, source order makes your later rules win in both modes.
@import "tailwindcss";
@import "@questpie/admin/client/styles/base.css";
@source "../node_modules/@questpie/admin/";
@source "../../../node_modules/@questpie/admin/";
:root,
.dark {
--primary: oklch(0.78 0.18 25);
--ring: oklch(0.78 0.18 25);
--sidebar-primary: oklch(0.78 0.18 25);
}
.light,
:root.light {
--primary: oklch(0.65 0.2 25);
--ring: oklch(0.65 0.2 25);
--sidebar-primary: oklch(0.65 0.2 25);
}
The :root.light bump in base.css is deliberate: in embedded setups the app's
global stylesheet often defines its own :root { --primary: ... } tokens, and
the extra specificity keeps them from leaking into the admin.
Working example: examples/tanstack-barbershop/src/admin.css (pink primary +
custom heading font, pure CSS whitelabel).
Content Localization
When collections have .localized() fields, the admin shows a locale switcher:
import { appConfig } from "questpie/app";
export default appConfig({
locale: {
locales: [
{ code: "en", label: { en: "English" }, flagCountryCode: "gb" },
{ code: "sk", label: { en: "Slovak" } },
],
defaultLocale: "en",
},
});
The admin tracks content locale separately from UI locale. Only localized field values change when switching.
Media & Uploads
avatar: f.upload({
to: "assets",
mimeTypes: ["image/*"],
maxSize: 5_000_000,
}),
The admin renders drag-and-drop upload, image preview, file info, and remove button.
Live Preview
Live Preview is one system: the existing collection FormView, Preview button, LivePreviewMode, and frontend iframe. Preserve the normal form lifecycle. Do not introduce a separate visual-edit form API, a second default form view, or a parallel preview surface.
The admin form is authoritative. The iframe mirrors form state through postMessage, supports field/block focus, and may request inline scalar edits. Persistence still goes through existing save, autosave, Cmd+S, history, workflow, locks, and actions.
Server Config
Add .preview() to a collection to enable split-screen editing:
export const pages = collection("pages")
.fields(({ f }) => ({
title: f.text().required().localized(),
slug: f.text().required(),
content: f.blocks().localized(),
}))
.preview({
enabled: true,
position: "right",
defaultWidth: 50,
url: ({ record }) => {
const slug = record.slug as string;
return slug === "home" ? "/?preview=true" : `/${slug}?preview=true`;
},
});
Preview opens the existing split-screen editor. Patches and refresh/resync messages update the iframe mirror; save/autosave still writes through the form.
Prepare a Frontend Page
Use exported APIs only: useCollectionPreview, PreviewProvider, PreviewField, and BlockRenderer.
Checklist:
- Configure
.preview({ url }) on the collection.
- Load the same record shape the page normally renders.
- For workflow-published pages, public reads use
stage: "published"; if the public client/HTTP API is exposed, anonymous read access also checks input?.stage === "published". Authorized preview/draft reads can load the working record.
- Call
useCollectionPreview({ initialData, onRefresh }) in the page renderer.
- Wrap the visual output in
PreviewProvider.
- Render from
preview.data, not the original loader object.
- Wrap editable scalar text with
PreviewField field="..." editable="text" | "textarea".
- Render block content with
BlockRenderer; pass selectedBlockId and onBlockClick.
- Keep add/remove/reorder/nesting block operations in the existing block editor.
import {
BlockRenderer,
PreviewField,
PreviewProvider,
useCollectionPreview,
} from "@questpie/admin/client";
import admin from "@/questpie/admin/.generated/client";
function PagePreview({ page }) {
const router = useRouter();
const preview = useCollectionPreview({
initialData: page,
onRefresh: () => router.invalidate(),
});
return (
<PreviewProvider preview={preview}>
<main className={preview.isPreviewMode ? "questpie-preview" : undefined}>
<PreviewField field="title" editable="text" as="h1">
{preview.data.title}
</PreviewField>
<BlockRenderer
content={preview.data.content}
data={preview.data.content?._data}
renderers={admin.blocks}
selectedBlockId={preview.selectedBlockId}
onBlockClick={
preview.isPreviewMode ? preview.handleBlockClick : undefined
}
/>
</main>
</PreviewProvider>
);
}
Key Principles
- Keep
FormView, the Preview button, and LivePreviewMode
- Never add a separate visual-edit form API, a second default form view, or parallel preview API names
- Preserve save/autosave/Cmd+S/history/workflow/locks/actions
useCollectionPreview handles preview mode, mirrored data, refresh/resync, and focus state
PreviewProvider supplies preview context to PreviewField
PreviewField annotates scalar fields and can opt into inline editing with editable
BlockRenderer preserves block IDs and block scopes for block annotations
- Use
BlockScopeProvider only for custom/manual block rendering outside BlockRenderer
- Validate all iframe messages before updating form state
History & Versions
Enable auditModule for activity timeline. Enable versioning on collections for snapshot restore:
export const pages = collection("pages")
.fields(({ f }) => ({ ... }))
.options({ versioning: true });
Scope (Multi-Tenancy)
The admin provides scope primitives for multi-tenant applications. Import from @questpie/admin/client.
ScopeProvider
Wraps the admin to enable scope selection. Manages scope ID in React state and persists to localStorage.
import { ScopeProvider } from "@questpie/admin/client";
<ScopeProvider
headerName="x-selected-workspace" // HTTP header for scope ID
storageKey="admin-workspace" // localStorage key
defaultScope={null} // default value
>
<AdminLayout>...</AdminLayout>
</ScopeProvider>;
ScopePicker
Dropdown for selecting the current scope. Place in sidebar via slots.afterBrand:
import { ScopePicker } from "@questpie/admin/client";
<AdminLayout
admin={admin}
basePath="/admin"
slots={{
afterBrand: (
<div className="px-3 py-2 border-b">
<ScopePicker
collection="workspaces"
labelField="name"
allowClear
compact
/>
</div>
),
}}
/>;
Three data sources: collection (queries a collection), options (static array), loadOptions (async function).
useScopedFetch / createScopedFetch
Inject scope header into all API calls:
import { useScopedFetch, createScopedFetch } from "@questpie/admin/client";
const scopedFetch = useScopedFetch();
const client = useMemo(
() => createClient({ baseURL: "/api", fetch: scopedFetch }),
[scopedFetch],
);
const scopedFetch = createScopedFetch("x-selected-workspace", () =>
getScopeId(),
);
useScope / useScopeSafe
Access current scope state in any component:
import { useScope, useScopeSafe } from "@questpie/admin/client";
const { scopeId, setScope, clearScope, headerName } = useScope();
const scope = useScopeSafe();
For the full server-side setup (context resolver, type augmentation, access rules), see the questpie skill's references/multi-tenancy.md.
OAuth Consent Screen (MCP over OAuth)
adminModule bundles the OAuth 2.1 authorization-server pieces (via starterModule), so an admin-enabled app is also the consent surface for MCP-over-OAuth. When an OAuth client (e.g. an MCP client) needs a signed-in user to approve its requested scopes, the provider redirects to two admin routes:
loginPage → /admin/login (the existing admin login) when there is no session.
consentPage → /admin/oauth/consent (built-in page, showInNav: false) to approve/deny.
The consent page reads client_id + scope from the signed authorization query in the URL, renders each requested scope as plain English (describeScopes), resolves the client's display name via GET /oauth2/public-client, and on Allow/Deny does POST /oauth2/consent with { accept, oauth_query } - then follows the returned redirect_uri back to the client. There is no getConsentHTML helper; the consent screen IS this admin page. Trusted clients (skip_consent) are approved server-side and never reach it.
It works out of the box - no wiring needed. To restyle it, the component is OAuthConsentPage and scope copy lives in oauth-scope-descriptions.ts (describeScope), which describes the QUESTPIE scope patterns (collections:<name>:read|write|delete, umbrellas, etc.) declaratively.
For the framework side (enabling the provider, the token flow, the scope model, scopes ∩ RBAC), see the questpie skill's references/mcp.md.
Common Mistakes
-
CRITICAL: Using asChild prop, QUESTPIE admin uses @base-ui/react, which uses the render prop. asChild is a Radix pattern and does NOT work here.
<DialogTrigger asChild><Button>Open</Button></DialogTrigger>
<DialogTrigger render={<Button>Open</Button>} />
-
CRITICAL: Importing from @radix-ui/*, use @base-ui/react instead.
-
HIGH: Using @phosphor-icons/react, use @iconify/react with ph: prefix.
import { CaretDown } from "@phosphor-icons/react";
import { Icon } from "@iconify/react";
<Icon icon="ph:caret-down" width={16} height={16} />;
-
HIGH: Using lucide-react icons, use @iconify/react with Phosphor icon set.
-
MEDIUM: Custom <button> or <div> instead of shadcn components, use <Button>, <Card>, etc.
-
MEDIUM: console.error for user errors, use toast.error() from sonner.
-
HIGH: Wrapping the admin in <div className="dark">, the provider
manages .dark/.light on <html>; a wrapper div misses portaled UI
(dialogs, popovers, toasts) and fights the built-in toggle. Pass
theme="dark" to AdminLayoutProvider instead.