PluginsAdminCP
Dashboard widgets
Add a configurable AdminCP dashboard widget to a VitNode plugin, with real data, a settings form, translated strings, sizing and an optional staff permission.
A dashboard widget is a card a plugin adds to the AdminCP dashboard. Each
administrator decides which widgets to show, where, and how big. In this guide
the site-notes plugin gets a Notes widget that shows how many notes exist
and the newest one, with a setting to hide the newest note.
Before you begin
- The
site-notesAdminCP page from AdminCP page. This guide reuses itssrc/pages/notes-query.ts, its@tanstack/react-querydependency and itssrc/admin/nav.tsx.
Build the widget component
Create src/admin/notes-widget.tsx. It reads the same notes query as the
AdminCP page, so the count is real and refreshes with the list:
import type { AdminDashboardWidgetProps } from '@vitnode/core/lib/plugin'
import { useQuery } from '@tanstack/react-query'
import { Skeleton } from '@vitnode/core/components/ui/skeleton'
import { useTranslations } from 'use-intl'
import { notesQueryOptions } from '@/pages/notes-query'
export const NotesWidget = ({ settings }: AdminDashboardWidgetProps) => {
const t = useTranslations('site-notes.admin.dashboard.widgets.notes')
const { data: notes, isError, isPending } = useQuery(notesQueryOptions())
const showLatest = settings.showLatest !== false
if (isPending) return <Skeleton className="m-2 h-9 w-24" />
if (isError) return <p className="text-destructive p-2 text-sm">{t('load_error')}</p>
return (
<div className="flex flex-col gap-1 p-2">
<strong className="text-3xl tabular-nums">
{t('count', { count: notes.length })}
</strong>
{showLatest && notes[0] ? (
<p className="text-muted-foreground text-sm leading-relaxed text-pretty">
{t('latest', { title: notes[0].title })}
</p>
) : null}
</div>
)
}settings holds what an administrator saved for this copy of the widget, and
is {} until they save anything, so the widget treats a missing showLatest
as on.
Add a settings form
Create src/admin/notes-widget-settings.tsx. The dashboard renders it in the
widget's panel while an administrator edits the layout.
useWidgetSettingsDialog() gives it save, which stores the settings, shows a
toast and refreshes the widget:
import type { AdminDashboardWidgetProps } from '@vitnode/core/lib/plugin'
import { AutoForm } from '@vitnode/core/components/form/auto-form'
import { AutoFormSwitch } from '@vitnode/core/components/form/fields/switch'
import { useWidgetSettingsDialog } from '@vitnode/core/views/admin/views/core/dashboard/grid/widget-settings-context'
import { useTranslations } from 'use-intl'
import { z } from 'zod'
export const NotesWidgetSettings = ({ settings }: AdminDashboardWidgetProps) => {
const t = useTranslations('site-notes.admin.dashboard.widgets.notes')
const { save } = useWidgetSettingsDialog()
const formSchema = z.object({
showLatest: z.boolean().default(settings.showLatest !== false),
})
return (
<AutoForm
fields={[
{
id: 'showLatest',
component: props => (
<AutoFormSwitch {...props} label={t('settings.show_latest')} />
),
},
]}
formSchema={formSchema}
onSubmit={async values => {
await save(values)
}}
submitButtonProps={{ children: t('settings.save') }}
/>
)
}Skip this step, and settingsComponent in the next one, if your widget has
nothing to configure.
Register it on the plugin's adminNav
Add a dashboard.widgets list to the adminNav in src/admin/nav.tsx, next to
the sidebar nav from the previous lesson. VitNode collects every plugin's
adminNav into the app's generated admin-nav.gen.ts, and the AdminCP shell
hands the widgets to the dashboard.
import type { AdminNavPluginSource } from '@vitnode/core/lib/plugin'
import { StickyNoteIcon } from 'lucide-react'
import { NotesWidget } from '@/admin/notes-widget'
import { NotesWidgetSettings } from '@/admin/notes-widget-settings'
import { manifest } from '@/manifest.gen'
export const adminNav = {
pluginId: manifest.id,
admin: {
dashboard: {
widgets: [
{
id: 'notes',
component: NotesWidget,
settingsComponent: NotesWidgetSettings,
icon: <StickyNoteIcon />,
defaultEnabled: true,
defaultSpan: 1,
defaultRows: 1,
},
],
},
nav: [
// ...the Notes link from the previous lesson
],
},
} satisfies AdminNavPluginSourceThe board namespaces the id as site-notes:notes, so notes never collides
with another plugin's idea of a notes card. With defaultEnabled, the dashboard
puts the widget at the end of the board for every administrator who has not
seen it yet. Anyone who removes it keeps it removed.
Add the strings
The dashboard loads the site-notes.admin.dashboard.widgets namespace. The
widget's title is notes.title and its optional desc shows in the widget
panel; the other keys are the ones the two components use. Add them under
site-notes.admin in src/locales/en.json, next to nav and notes:
{
"site-notes": {
"admin": {
// ...nav and notes from the previous lesson
"dashboard": {
"widgets": {
"notes": {
"title": "Notes",
"desc": "How many notes exist, and the newest one.",
"count": "{count, plural, one {# note} other {# notes}}",
"latest": "Latest: {title}",
"load_error": "The notes could not be loaded.",
"settings": {
"show_latest": "Show the newest note",
"save": "Save settings"
}
}
}
}
}
}
}Check the result
Let the plugin rebuild (or run its build:plugins script), then reload the
AdminCP dashboard at /admin/core.
- A Notes card sits at the end of the board, showing the number of notes
and "Latest:" with the newest title. With no notes yet it reads
0 notes.

- Press Edit layout, click the card, switch off Show the newest note and choose Save settings. A toast confirms the save and the card shows only the count.
- Create a note on the plugin's AdminCP page, then come back. The count has gone up by one.
Show a widget only to some staff
permission hides a widget from administrators without that staff permission:
{
id: 'notes',
component: NotesWidget,
permission: { module: 'notes', permission: 'can_create' },
// ...
}- It only hides the card. The data still comes from the API, so a widget
that shows private data must read it from a route guarded with
adminStaffPermission, as the create endpoint in Permissions is. The notes list is public, so the Notes widget needs no guard. - Declare the permission in
buildApiPlugin({ permissionStaff }), assite-notesalready does fornotes.can_create. An undeclared permission cannot be granted in the staff editor, so only root administrators would see the widget.vitnode plugin validatechecks this for sidebar links, not for widgets.
Widget options
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
id | string | Yes | - | Unique within the plugin; the board uses {pluginId}:{id}. Also names the strings under admin.dashboard.widgets.{id} |
component | ComponentType<AdminDashboardWidgetProps> | Yes | - | Renders the widget's body. Gets settings and widgetId (the id of this copy on the board) |
icon | ReactNode | No | - | Shown in the widget panel |
defaultSpan | 1 | 2 | 3 | No | minSpan | Width in grid columns when first placed |
defaultRows | 1 | 2 | 3 | No | 1 | Height in grid rows when first placed |
minSpan | 1 | 2 | 3 | No | 1 | The narrowest an administrator can resize it to |
defaultEnabled | boolean | No | false | Placed automatically for administrators who have never seen it |
allowMultiple | boolean | No | false | Lets an administrator place more than one copy, each with its own settings |
category | string | No | the plugin | Groups the widget in the panel, titled by admin.dashboard.widgets.categories.{category} (the raw value when that key is missing) |
permission | { module, permission } | No | - | Hides the widget from administrators without this staff permission of the declaring plugin |
settingsComponent | ComponentType<AdminDashboardWidgetProps> | No | - | A form for this copy's settings, shown while an administrator edits the dashboard. Saves with useWidgetSettingsDialog().save |
How admins arrange them
Your widget does not need any code for this - it is what the dashboard does with every widget. Press Edit layout and the Widgets panel slides in beside the grid.
| To⦠| Do this |
|---|---|
| Add a widget | Click it in the panel to put it at the end, or drag it onto the grid. While you drag, the grid opens a gap the widget's real size, right where it will land. |
| Change your mind | Drag it back over the panel and let go. Nothing is added, and the card glides back to where it came from. |
| Move a widget | Drag the card from anywhere on it, or Tab to it and use Space and the arrow keys. The spot it came from turns into a dashed slot. |
| Resize a widget | Grab the little bracket in the card's bottom corner and pull. Sideways snaps the width between 1 and 3 columns, down and up snap the height between 1 and 3 rows, and the other widgets glide out of the way. A dashed outline follows your pointer so you can see where you are pulling. From the keyboard, Tab to the corner and use the arrow keys. |
| Edit a widget | Click the card (or Tab to it and press Enter). The panel switches to that widget: a width switch, its own settings form if it has one, and a bin in the header. Save settings stores the settings straight away and leaves the rest of your unsaved arrangement alone. The arrow at the top goes back to the list. |
| Remove a widget | The bin on the card's toolbar, or in its panel. |
| Finish | The panel footer says whether anything changed. Save keeps the arrangement; Cancel throws it away and leaves edit mode. |
New widgets fade in and scroll into view, and screen readers hear where a widget was picked up and dropped in the admin's own language. With reduce motion turned on in the operating system, all of it happens instantly.
The drop gap uses defaultSpan and defaultRows, so pick them to match what the widget looks like the moment it is added.