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
| Property | Type | Description |
|---|---|---|
name * | string | Unique plugin identifier |
version * | string | Plugin version (semver) |
fieldPlugins | VextroFieldPlugin[] | Custom field type registrations |
hooks | VextroPluginHooks | Document lifecycle hooks |
toolbarActions | VextroToolbarAction[] | Buttons added to the admin toolbar |
dashboardWidgets | VextroDashboardWidget[] | Widgets rendered on the dashboard page |
sidebarWidgets | VextroSidebarWidget[] | 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,
}; | Option | Type | Description |
|---|---|---|
fieldType * | string | Unique field type identifier |
label | string | Human-readable label (shown in field type pickers) |
component * | Svelte component | Must accept VextroCustomFieldProps |
validator | (value, config?) => string | null | Client-side validation; return error message or null |
serializer | (value) => unknown | Transform editor value to storage format |
deserializer | (value) => unknown | Transform 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
},
},
}); | Hook | Context Type | Timing | Can Mutate Data? |
|---|---|---|---|
onDocumentCreate | VextroHookContext | After document creation | No |
onDocumentUpdate | VextroHookContext | After document update | No |
onDocumentDelete | VextroHookContext | After document deletion | No |
onBeforeSave | VextroBeforeSaveContext | Before persistence (create or update) | Yes (ctx.data) |
onAfterSave | VextroAfterSaveContext | After 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
},
},
],
}); | Option | Type | Description |
|---|---|---|
label * | string | Button label |
icon | unknown | Icon 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,
},
],
}); | Option | Type | Description |
|---|---|---|
label * | string | Widget label |
component * | Svelte component | Widget component |
width * | "full" | "half" | "third" | CSS grid width |
priority * | number | Sort order (lower numbers appear first) |
Sidebar Widgets
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"],
},
],
}); | Option | Type | Description |
|---|---|---|
label * | string | Widget label |
component * | Svelte component | Widget component |
position * | "top" | "bottom" | Position within the sidebar |
collectionFilter | string[] | 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}"`);
},
},
});
}