Customization

Plugins

Plugins are the primary extension mechanism for Vextro. A plugin bundles together custom field types, UI widgets, and lifecycle hooks into a single registerable unit.

Defining a Plugin

Use the definePlugin helper for type checking and autocomplete:

import { definePlugin } from "vextro";

export const myPlugin = definePlugin({
  name: "my-plugin",
  version: "1.0.0",
  fieldPlugins: [],
  hooks: {},
  toolbarActions: [],
  dashboardWidgets: [],
  sidebarWidgets: [],
});

Plugin Descriptor

PropertyTypeDescription
name *stringUnique plugin identifier
version *stringPlugin version (semver)
fieldPluginsVextroFieldPlugin[]Custom field type registrations
hooksVextroPluginHooksDocument lifecycle hooks
toolbarActionsVextroToolbarAction[]Buttons added to the admin toolbar
dashboardWidgetsVextroDashboardWidget[]Widgets rendered on the dashboard page
sidebarWidgetsVextroSidebarWidget[]Widgets rendered in the document edit sidebar

Registering Plugins

Pass plugins to VextroConfig.plugins:

import { createVextroClient } from "vextro";
import { examplePlugin } from "@vextro/example-plugin";

const client = createVextroClient({
  convexUrl: import.meta.env.PUBLIC_CONVEX_URL,
  config: {
    brandName: "My Admin",
    plugins: [examplePlugin()],
  },
});

Plugins are registered in order. If two plugins register the same field type, the later one wins (with a console warning).

Extension Points

Custom Field Types

Plugins can register entirely new field types with custom Svelte components, validation, and serialization.

import type { VextroFieldPlugin } from "vextro";
import MyCustomInput from "./components/MyCustomInput.svelte";

const myField: VextroFieldPlugin = {
  fieldType: "starRating",
  label: "Star Rating",
  component: MyCustomInput,
  validator: (value, config) => {
    const max = (config?.maxStars as number) ?? 5;
    if (typeof value !== "number" || value < 1 || value > max) {
      return `Rating must be between 1 and ${max}`;
    }
    return null;
  },
  serializer: (value) => value,
  deserializer: (value) => value,
};
OptionTypeDescription
fieldType *stringUnique field type identifier
labelstringHuman-readable label (shown in field type pickers)
component *Svelte componentMust accept VextroCustomFieldProps
validator(value, config?) => string | nullClient-side validation; return error message or null
serializer(value) => unknownTransform editor value to storage format
deserializer(value) => unknownTransform storage value to editor format

Custom Field Component Props

Your Svelte component receives VextroCustomFieldProps:

<script lang="ts">
  import type { VextroCustomFieldProps } from "vextro";

  let {
    name,
    value,
    onChange,
    fieldDefinition,
    disabled = false,
    error,
  }: VextroCustomFieldProps = $props();

  let localValue = $state(value ?? "");

  function handleInput(e: Event) {
    localValue = (e.target as HTMLInputElement).value;
    onChange?.(localValue);
  }
</script>

<input value={localValue} oninput={handleInput} {disabled} />
<!-- Hidden input for form POST when onChange is not provided (SSR context) -->
<input type="hidden" {name} value={localValue} />

When rendered at the top level via Astro SSR, onChange is not provided because functions cannot cross the SSR-to-client boundary. In that case, your component must include a <input type="hidden"> for form POST serialization. When rendered inside a Svelte parent (group, array), onChange is provided and handles value propagation automatically.

Lifecycle Hooks

Plugins can tap into the document lifecycle to run custom logic on create, update, and delete operations.

definePlugin({
  name: "audit-logger",
  version: "1.0.0",
  hooks: {
    onDocumentCreate: async (ctx) => {
      console.log(`Created in ${ctx.collectionSlug}`, ctx.documentId);
    },
    onDocumentUpdate: async (ctx) => {
      console.log(`Updated in ${ctx.collectionSlug}`, ctx.documentId);
    },
    onDocumentDelete: async (ctx) => {
      console.log(`Deleted from ${ctx.collectionSlug}`, ctx.documentId);
    },
    onBeforeSave: async (ctx) => {
      // Can mutate ctx.data before persistence
      if (ctx.isNew) {
        ctx.data.createdVia = "admin";
      }
    },
    onAfterSave: async (ctx) => {
      // Read-only post-save processing
    },
  },
});
HookContext TypeTimingCan Mutate Data?
onDocumentCreateVextroHookContextAfter document creationNo
onDocumentUpdateVextroHookContextAfter document updateNo
onDocumentDeleteVextroHookContextAfter document deletionNo
onBeforeSaveVextroBeforeSaveContextBefore persistence (create or update)Yes (ctx.data)
onAfterSaveVextroAfterSaveContextAfter persistence (create or update)No

Plugin hooks are global — they fire for all collections. For collection-specific hooks, use the hooks option on defineVextroCollection instead. See Hooks for details.

Toolbar Actions

Add buttons to the admin toolbar:

definePlugin({
  name: "export-tools",
  version: "1.0.0",
  toolbarActions: [
    {
      label: "Export CSV",
      position: "right",
      handler: async () => {
        // Export logic
      },
    },
  ],
});
OptionTypeDescription
label *stringButton label
iconunknownIcon component, SVG string, or icon name
handler *() => void | Promise<void>Click handler
position *"left" | "right"Position in the toolbar

Dashboard Widgets

Add widgets to the admin dashboard:

import AnalyticsWidget from "./components/AnalyticsWidget.svelte";

definePlugin({
  name: "analytics",
  version: "1.0.0",
  dashboardWidgets: [
    {
      label: "Content Analytics",
      component: AnalyticsWidget,
      width: "half",
      priority: 10,
    },
  ],
});
OptionTypeDescription
label *stringWidget label
component *Svelte componentWidget component
width *"full" | "half" | "third"CSS grid width
priority *numberSort order (lower numbers appear first)

Add widgets to the document edit sidebar:

import WordCountWidget from "./components/WordCountWidget.svelte";

definePlugin({
  name: "word-count",
  version: "1.0.0",
  sidebarWidgets: [
    {
      label: "Word Count",
      component: WordCountWidget,
      position: "bottom",
      collectionFilter: ["posts", "pages"],
    },
  ],
});
OptionTypeDescription
label *stringWidget label
component *Svelte componentWidget component
position *"top" | "bottom"Position within the sidebar
collectionFilterstring[]Only show for these collection slugs. Omit to show on all.

Plugin Registry

Under the hood, Vextro uses a plugin registry to manage all registered plugins. You typically don't interact with the registry directly — it's created automatically from your config. For advanced use cases:

import { createPluginRegistry } from "vextro";

const registry = createPluginRegistry();
registry.register(myPlugin);

// Lookup a custom field renderer
const renderer = registry.getFieldRenderer("starRating");

// Get all toolbar actions
const actions = registry.getToolbarActions();

// Run lifecycle hooks
await registry.runHook("onBeforeSave", {
  collectionSlug: "posts",
  data: { title: "Hello" },
  isNew: true,
});

Publishing a Plugin

Publish plugins as standalone npm packages with vextro (and svelte if providing components) as peer dependencies:

{
  "name": "vextro-plugin-my-feature",
  "peerDependencies": {
    "vextro": "^0.2.0",
    "svelte": "^5.0.0"
  }
}

See plugins/example-plugin in the Vextro repository for a complete starter template.

Example: Star Rating Plugin

A complete example demonstrating custom field registration and lifecycle hooks:

import { definePlugin } from "vextro";
import VextroStarRatingInput from "./components/VextroStarRatingInput.svelte";

export function starRatingPlugin() {
  return definePlugin({
    name: "star-rating",
    version: "1.0.0",
    fieldPlugins: [
      {
        fieldType: "starRating",
        label: "Star Rating",
        component: VextroStarRatingInput,
        validator: (value, config) => {
          const max = (config?.maxStars as number) ?? 5;
          if (value !== undefined && value !== null && value !== 0) {
            if (typeof value !== "number" || value < 1 || value > max) {
              return `Rating must be between 1 and ${max}`;
            }
          }
          return null;
        },
      },
    ],
    hooks: {
      onDocumentCreate: async (ctx) => {
        console.log(`Document created in "${ctx.collectionSlug}"`);
      },
      onDocumentUpdate: async (ctx) => {
        console.log(`Document updated in "${ctx.collectionSlug}"`);
      },
    },
  });
}
Previous
Internationalization