Plugins

Main Navigation

Let administrators build the header menu from prebuilt plugin pages and custom links, and offer your plugin's pages as one-click menu items.

The links in the site header are not hard-coded. Administrators build the menu in AdminCP → Core → System → Navigation, and every item there is one of two things:

  • a prebuilt page - a destination a plugin declared, so the admin picks it from a list and never types a URL;
  • a custom link - any address, on this site or elsewhere.

Both carry a title and a description in every enabled language, can open in a new tab, can be dragged into any order, and can be tucked one level under another item to form a dropdown. Your plugin's job is small: tell VitNode which of its pages are worth a spot in the menu.

Offer a prebuilt page

Declare the preset

Add a navigation array to the plugin's API config. An id and an href are all a preset needs; isOpenInNewTab is optional and off by default.

plugins/site-notes/src/config.api.ts
import { buildApiPlugin } from '@vitnode/core/api/lib/plugin'

export const siteNotesApiPlugin = () =>
  buildApiPlugin({
    pluginId: '@acme/site-notes',
    navigation: [
      { href: '/notes', icon: 'notebook-pen', id: 'notes' },
      {
        href: 'https://status.acme.dev',
        icon: 'activity',
        id: 'status',
        isOpenInNewTab: true,
      },
    ],
    modules: [],
  })

The id is lowercase letters, digits, - and _. The href starts with / for a page on this site or with https:// for anything else - plaintext http:// is refused, for presets and for what an administrator types. The optional icon is a lucide name in kebab-case, such as notebook-pen. VitNode refuses a preset that breaks any of those rules at boot, with a message naming the plugin, so a typo never reaches an administrator.

Name it in the locale file

The default title and description live under navigation.<id> in the plugin's own message tree. That is what the admin sees in the picker and what visitors see in the header until an admin overrides it.

plugins/site-notes/src/locales/en.json
{
  "@acme/site-notes": {
    "navigation": {
      "notes": {
        "title": "Notes",
        "description": "Short posts from the team."
      }
    }
  }
}

description is optional. A preset with no title in any language is skipped by the header rather than shown as a raw key, so this step is not decorative.

Add it to the menu

Start the app
bun dev

Open AdminCP → Core → System → Navigation, click Add menu item, keep Prebuilt page selected and pick Notes. It appears in the header immediately.

What administrators can change

SettingPrebuilt pageCustom link
Title, descriptionOptional override per language, defaults to yoursRequired
URLAlways the plugin's href, even after it changesAny /path or https:// address
IconYours by default, replaceableOptional
Open in a new tabYesYes
OrderDrag and dropDrag and drop
NestingOne level, as a dropdownOne level, as a dropdown

Icons

Every menu item can carry a lucide icon, picked from the same picker a role prefix uses, restricted to its icon half - no emoji. A prebuilt item starts on the icon its plugin declared and the picker opens on it; the remove button on the field goes back to that default rather than storing a blank. Icons are stored as icon:<name> and drawn server-side, exactly like a role's prefix.

Order and dropdowns

Every item in the list has a grip handle. Drag it up or down to reorder, or drop it a little to the right to nest it under the item above. The menu is one level deep on purpose: a header dropdown with sub-dropdowns is a maze, not a menu, so an item that already has children stays at the top level and nothing can be dropped under a child.

Keyboard works too: focus a handle, press Space, move with the arrow keys - nests, un-nests - and press Space again to drop.

A top-level item with children renders in the header as a dropdown: the parent opens it on hover and still follows its own URL when clicked, and its children are the links, each with its description under the label. Remove a parent and its children move back to the top level rather than disappearing.

Because a preset item stores only pluginId and id, moving a route in your plugin moves every menu that links to it. Uninstalling the plugin hides its items from the header and flags them in the AdminCP list, without deleting the admin's titles - reinstall the plugin and they are back.

How the header gets the menu

The menu ships inside the middleware endpoint every page already reads, next to the SSO and captcha configuration:

const response = await fetcher({
  plugin: '@vitnode/core',
  method: 'get',
  module: 'middleware',
  path: '/',
})

const { navigation } = await response.json()

Each top-level entry carries the resolved href, icon, isOpenInNewTab, the stored title and description overrides for every language, and an items array with the same shape for its dropdown. Preset defaults are translated in the browser from the plugin's navigation namespace, which the header warms for exactly the plugins whose presets are in the menu.

On the server the menu is cached for a day (c.get("cache"), Redis when REDIS_URL is set) and expired the moment an administrator saves a change, so "long cache" and "instant updates" are not in tension. Without Redis it is one small query per request.

Permissions

Everything under Navigation is gated by the core navigation staff module:

PermissionLets an administrator
can_viewOpen the screen and see the list
can_createAdd items
can_editEdit and reorder items
can_deleteRemove items

Grant them per role or per admin under Staff → Administrators, like any other staff permission.

Events

Every change emits a built-in event: navigation.created, navigation.updated, navigation.reordered and navigation.deleted. Listen to them to purge a CDN or keep an audit trail.

Next