LLM Reference

LLM Reference: Custom Components

Svelte 5 Runes

All Svelte components in Vextro and consuming apps must use Svelte 5 rune syntax. Do not use Svelte 3/4 patterns (export let, $:, <slot />).

Rune Reference

RuneReplacesPurpose
$state()let x = valueReactive mutable state
$derived()$: x = exprComputed values from state
$derived.by(() => ...)$: { ... }Computed values with multi-line logic
$effect(() => ...)$: { sideEffect() }Side effects that run on state change
$props()export letComponent props

Example: custom input component using all four runes

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

  // $props() replaces "export let" — props are typed via the interface
  let {
    name,
    value,
    onChange,
    fieldDefinition,
    disabled = false,
    error,
  }: VextroCustomFieldProps = $props();

  // $state() — reactive mutable state (replaces "let x = value")
  let localValue = $state(value ?? "");
  let copyState = $state<"idle" | "copied">("idle");

  // $derived() — computed from state (replaces "$: x = expr")
  let charCount = $derived(String(localValue).length);
  let isOverLimit = $derived(charCount > 100);

  // $derived.by() — multi-line computed logic
  let statusText = $derived.by(() => {
    if (isOverLimit) return `${charCount}/100 — too long`;
    return `${charCount}/100`;
  });

  // $effect() — side effect when state changes (replaces "$: { ... }")
  $effect(() => {
    if (localValue !== value) {
      onChange?.(localValue);
    }
  });

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

<input value={localValue} oninput={handleInput} {disabled} />
<input type="hidden" {name} value={localValue} />
{#if error}
  <p class="text-xs text-danger">{error}</p>
{/if}
<p class="text-xs text-fg-muted">{statusText}</p>

Event handling

Use callback props instead of custom events or on: directives:

<!-- Correct: callback prop -->
<script lang="ts">
  interface Props {
    onchange?: (value: string) => void;
  }
  let { onchange }: Props = $props();
</script>
<input onchange={(e) => onchange?.(e.currentTarget.value)} />

<!-- Avoid: legacy on: directive -->
<input on:change />

Snippets vs Slots

Svelte 5 uses snippets instead of <slot />. Import Snippet from 'svelte' and render with {@render}.

<script lang="ts">
  import type { Snippet } from "svelte";

  interface Props {
    header?: Snippet;
    children: Snippet;
    footer?: Snippet<[{ closeModal: () => void }]>;
  }

  let { header, children, footer }: Props = $props();

  function closeModal() { /* ... */ }
</script>

<div class="card">
  {#if header}
    <div class="card-header">{@render header()}</div>
  {/if}
  <div class="card-body">{@render children()}</div>
  {#if footer}
    <div class="card-footer">{@render footer({ closeModal })}</div>
  {/if}
</div>

Snippets can also be defined inline in the parent and passed as props:

<MyCard>
  {#snippet header()}
    <h2>Title</h2>
  {/snippet}
  Default children content here.
</MyCard>

SvelteFieldRenderer Field Type Mapping

SvelteFieldRenderer is the central client-side dispatcher that maps a field’s fieldType string to the correct input component. When building custom fields or plugins, use this table to understand the built-in mapping and where custom types plug in.

fieldTypeComponentNotes
text, email, url, passwordVextroTextInputAll four map to the same text input
textareaVextroTextareaInputMulti-line text
numberVextroNumberInputNumeric input
checkboxVextroCheckboxInputInline label rendering (no wrapper)
dateVextroDateInputDate-only picker
datetimeVextroDateTimePickerDate + time picker; accepts locale, hourCycle config
slugVextroSlugInputURL-safe slug
jsonVextroJsonInputRaw JSON editor
selectVextroSelectInputSingle-select dropdown
select + config.multiple: trueVextroMultiSelectInputMulti-select (same fieldType, different config)
multiselectVextroMultiSelectInputExplicit multi-select alias
radioVextroRadioInputRadio group; accepts config.layout: "horizontal" | "vertical"
codeVextroCodeInputCode editor with syntax highlighting
richTextVextroWysiwygTipTapTipTap rich text; config.output: "json" | "html"
colorVextroColorInputColor picker; accepts config.formats
pointVextroPointInputLat/lng picker; accepts config.showMap, config.defaultCenter
seoVextroSeoEditorSEO metadata block
groupVextroGroupInputNested field group
arrayVextroArrayInputRepeating row array
blocksVextroBlockInputBlock array editor
relationshipVextroRelationshipInputRelationship picker; accepts config.multiple, config.displayAs
upload, image + config.relationTo, file + config.relationToVextroUploadIslandFile/image upload
join(inline)Read-only reverse relationship; not editable
section(inline)Visual divider/heading only; no data
virtual(inline)Computed read-only display
rowSvelteRowContainerLayout container: horizontal row of fields
collapsibleSvelteCollapsibleContainerLayout container: collapsible section
tabsSvelteTabsContainerLayout container: tabbed sections
(registered plugin type)VextroCustomFieldWrapperAny type registered via setPluginFieldRenderers
(unknown)(fallback)Shows “Unknown field type” warning + hidden input

Plugin renderer resolution order

  1. Check the pluginRenderers prop passed from the Astro layer (Svelte-to-Svelte rendering).
  2. Fall back to getPluginFieldRenderers() from the client store (Astro island rendering).
  3. If no renderer found, render the fallback warning.

Bits UI Compound Component Pattern

Vextro uses Bits UI for accessible interactive primitives. All Bits UI components follow a compound pattern where Root wraps child sub-components.

Import pattern

<script lang="ts">
  import { Dialog, Select, DropdownMenu, Popover, Checkbox } from "bits-ui";
</script>

Dialog example

<Dialog.Root>
  <Dialog.Trigger class="btn">Open</Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay class="fixed inset-0 bg-black/50" />
    <Dialog.Content class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface rounded-lg p-6">
      <Dialog.Title>Confirm action</Dialog.Title>
      <Dialog.Description>This cannot be undone.</Dialog.Description>
      <Dialog.Close class="btn">Cancel</Dialog.Close>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

Available primitives

PrimitiveSub-components
DialogRoot, Trigger, Portal, Overlay, Content, Title, Description, Close
DropdownMenuRoot, Trigger, Portal, Content, Item, Sub, SubTrigger, SubContent, Separator, Label
SelectRoot, Trigger, Value, Portal, Content, Item, ItemText, Separator, Label
PopoverRoot, Trigger, Portal, Content, Close
CheckboxRoot, Indicator
RadioGroupRoot, Item, Indicator
SwitchRoot, Thumb
TabsRoot, List, Trigger, Content
AccordionRoot, Item, Header, Trigger, Content
ComboboxRoot, Input, Portal, Content, Item, ItemText, Empty

Key rules when using Bits UI

  • Always include Dialog.Title and Dialog.Description inside Dialog.Content for accessibility.
  • Use Dialog.Portal to render overlays outside the normal DOM flow.
  • Provide aria-label on any trigger that lacks visible text.
  • Bits UI components are accessible by default — do not override role/aria attributes unless required.

VextroPlugin

The core plugin type. Bundles custom field renderers, toolbar actions, widgets, and hooks.

type VextroPlugin = {
  name: string;
  version: string;
  hooks?: VextroPluginHooks;
  fieldPlugins?: VextroFieldPlugin[];
  toolbarActions?: VextroToolbarAction[];
  dashboardWidgets?: VextroDashboardWidget[];
  sidebarWidgets?: VextroSidebarWidget[];
};

VextroPluginHooks

type VextroPluginHooks = {
  onDocumentCreate?: (context: VextroHookContext) => void | Promise<void>;
  onDocumentUpdate?: (context: VextroHookContext) => void | Promise<void>;
  onDocumentDelete?: (context: VextroHookContext) => void | Promise<void>;
  onBeforeSave?: (context: VextroBeforeSaveContext) => void | Promise<void>;
  onAfterSave?: (context: VextroAfterSaveContext) => void | Promise<void>;
};

type VextroHookContext = {
  collectionSlug: string;
  documentId?: string;
  data: Record<string, unknown>;
  user?: { id: string; email?: string; [key: string]: unknown };
};

type VextroBeforeSaveContext = VextroHookContext & { isNew: boolean };
type VextroAfterSaveContext = VextroHookContext & { isNew: boolean };

VextroFieldPlugin

Register custom field types with custom rendering, validation, and serialization.

type VextroFieldPlugin = {
  fieldType: string;
  label?: string;
  component: unknown;  // Svelte component
  validator?: (value: unknown, config?: Record<string, unknown>) => string | null;
  serializer?: (value: unknown) => unknown;
  deserializer?: (value: unknown) => unknown;
};
FieldDescription
fieldTypeUnique identifier (e.g., "starRating", "mapPicker")
labelHuman-readable name for field type pickers
componentSvelte component accepting VextroCustomFieldProps
validatorClient-side validation. Return null if valid, error string if not
serializerTransform editor value to database format
deserializerTransform database value to editor format

VextroCustomFieldProps

Props that custom field Svelte components must accept:

type VextroCustomFieldProps = {
  name: string;
  value: unknown;
  onChange?: (value: unknown) => void;
  fieldDefinition: VextroCustomFieldDefinition;
  disabled?: boolean;
  error?: string;
};

type VextroCustomFieldDefinition = {
  name: string;
  label: string;
  fieldType: string;
  config?: Record<string, unknown>;
  description?: string;
};

Important: onChange availability

  • Top-level fields (rendered by Astro SSR via VextroFieldInput.astro): onChange is NOT provided because functions cannot cross the SSR-to-client serialization boundary. The component must include its own <input type="hidden" name={name} value={...} /> for form POST serialization.
  • Nested fields (inside Svelte parent like group/array via VextroCustomFieldWrapper): onChange IS provided and handles value propagation automatically.

Example Svelte Component

<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={disabled} />
<input type="hidden" {name} value={localValue} />

VextroToolbarAction

type VextroToolbarAction = {
  label: string;
  icon?: unknown;
  handler: () => void | Promise<void>;
  position: "left" | "right";
};

VextroDashboardWidget

type VextroDashboardWidget = {
  label: string;
  component: unknown;
  width: "full" | "half" | "third";
  priority: number;  // lower = appears first
};

VextroSidebarWidget

type VextroSidebarWidget = {
  label: string;
  component: unknown;
  position: "top" | "bottom";
  collectionFilter?: string[];  // only show for these collection slugs
};

definePlugin

Helper to type-check a plugin definition (pass-through):

import { definePlugin } from "vextro";

const myPlugin = definePlugin({
  name: "my-plugin",
  version: "1.0.0",
  fieldPlugins: [{ fieldType: "starRating", component: StarRating }],
});

Registering Custom Field Renderers

Svelte islands rendered via Astro’s client:* directives cannot receive non-serializable props. Use setPluginFieldRenderers to register custom renderers at app startup.

setPluginFieldRenderers

function setPluginFieldRenderers(renderers: Map<string, VextroFieldPlugin>): void;

Call once during app initialization in a headless Svelte component with client:load:

<!-- src/components/PluginInit.svelte -->
<script lang="ts">
  import { setPluginFieldRenderers } from "vextro";
  import type { VextroFieldPlugin } from "vextro";
  import StarRatingInput from "./StarRatingInput.svelte";

  const renderers = new Map<string, VextroFieldPlugin>();
  renderers.set("starRating", {
    fieldType: "starRating",
    component: StarRatingInput,
  });
  setPluginFieldRenderers(renderers);
</script>
<!-- AdminLayout.astro -->
<PluginInit client:load />
<slot />

Custom Relationship Renderers

Custom renderers for how relationship items display in the editor. Renderers are keyed by collection slug (e.g., "employees") or by "{collectionSlug}:{fieldName}" for per-field overrides.

import { setRelationshipRenderers, getRelationshipRenderer } from "vextro";

type RelationshipRenderer =
  | ((doc: Record<string, unknown>) => string)
  | ((doc: Record<string, unknown>) => { component: unknown; props: Record<string, unknown> });

setRelationshipRenderers

function setRelationshipRenderers(renderers: Map<string, RelationshipRenderer>): void;

Register renderers in a headless Svelte component rendered with client:load:

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

  const renderers = new Map();
  renderers.set("employees", (doc) => `${doc.firstName} ${doc.lastName} — ${doc.email}`);
  renderers.set("employees:manager", (doc) => ({
    component: EmployeeCard,
    props: { name: doc.firstName, avatar: doc.avatar },
  }));
  setRelationshipRenderers(renderers);
</script>

getRelationshipRenderer / getRelationshipRenderers

function getRelationshipRenderer(key: string): RelationshipRenderer | undefined;
function getRelationshipRenderers(): Map<string, RelationshipRenderer>;

At render time, VextroRelationshipInput checks this store when a relationship field’s displayAs is set to "__custom" (the serialized marker for function-based display).

VextroInputOverrides

type VextroInputComponent = (props: Record<string, unknown>) => unknown;

type VextroInputOverrides = {
  components?: Record<string, VextroInputComponent>;
  context?: Record<string, unknown>;
};

Set in VextroConfig to globally override built-in field input components:

const config: VextroConfig = {
  brandName: "My App",
  inputOverrides: {
    components: {
      richText: MyCustomRichTextEditor,
    },
    context: { apiKey: "..." },
  },
};

f.custom() and f.ui()

f.custom(validator, adminType, config?)

Register a field with a raw Convex validator and a custom admin type:

address: f.custom(
  v.object({ street: v.string(), city: v.string(), zip: v.string() }),
  "address",
  { defaultCountry: "US" }
)

The adminType must match a registered fieldType from a plugin or input override.

f.ui()

Create a UI-only slot (no data storage):

preview: f.ui({
  component: "DocumentPreview",
  props: { showMeta: true },
  watchFields: ["title", "body"],
})
OptionTypeDescription
componentstringRegistered component name
propsRecord<string, unknown>Props passed to component
watchFieldsstring[]Re-render when these fields change
labelstringDisplay label
conditionFieldConditionConditional display
Previous
Admin Module