---
title: "Custom fields end to end"
description: "Ship a field's server descriptor and React renderer as one typed contract."
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom fields end to end

A custom field is a two-sided protocol contract: a server `Field` subclass emits a descriptor `type`, and a UI plugin registers a component for that exact type. Use a vendor prefix; `adonia/*` is reserved.

This example builds `acme/currency` on the existing textual field behavior, so validation, hydration, dehydration, visibility, fill allowlisting, and reactive rules remain framework-owned.

## 1. Define the server field

```ts
import { TextInput } from '@adonia/core'

export class CurrencyField extends TextInput {
  static override type = 'acme/currency'
  static override displayType = 'text-entry'
  static override columnType = 'number'

  static override make(attribute: string): CurrencyField {
return new CurrencyField(attribute)
  }

  currency(code: string): this {
return this.withProps({ currency: code })
  }
}

export const AcmeFields = {
  currency: (attribute: string) => CurrencyField.make(attribute),
}
```

A field subclass must provide a stable `static type` and validation behavior. Extending a built-in is preferable when its state and Vine mapping already match. For a new state shape, extend `Field<TValue>` and implement `vineSchema`; also decide `displayType`, `columnType`, default/hydrate/dehydrate behavior, and whether the field can derive a table column.

Use the field in a resource schema like every built-in:

```ts
import { BaseResource, type SchemaBuilder, type SchemaComponent } from '@adonia/core'
import Post from '#models/post'
import { AcmeFields } from '#adonia/fields/currency_field'

export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'

  override schema(_schema: SchemaBuilder): SchemaComponent[] {
return [AcmeFields.currency('price').label('Price').currency('USD').required()]
  }
}
```

Only JSON-safe values may enter the descriptor. `withProps()` writes the custom `currency` prop while inherited calls such as `label`, `required`, `readonlyOn`, `visibleWhen`, `disabledWhen`, and `dependsOn` retain their normal semantics.

## 2. Register the client renderer

```tsx
import type { FieldProps, NamedAdoniaUiPlugin } from '@adonia/ui'

function CurrencyInput({ node, control }: FieldProps) {
  const label = String(node.props.label ?? node.key)
  const currency = String(node.props.currency ?? '')
  const value = typeof control.value === 'string' ? control.value : ''

  return (
<label>
  <span>{label}</span>
  <span>{currency}</span>
  <input
    name={node.key ?? undefined}
    inputMode="decimal"
    value={value}
    disabled={control.disabled ?? undefined}
    aria-invalid={control.error === null ? undefined : true}
    onChange={(event) => control.setValue(event.currentTarget.value)}
  />
  {control.error === null ? null : <span role="alert">{control.error}</span>}
</label>
  )
}

export const acmeFieldsUi: NamedAdoniaUiPlugin = {
  name: '@acme/adonia-fields',
  version: '1.0.0',
  protocolVersion: 1,
  register(registry) {
registry.field('acme/currency', CurrencyInput)
  },
}
```

Install the UI half through the application registry:

```ts
import { adonia } from '@adonia/ui'
import { acmeFieldsUi } from '#adonia/fields/currency_input'

export default adonia({
  registry(registry) {
registry.use(acmeFieldsUi)
  },
})
```

The component is controlled by `FieldController`: read `control.value`, write through `control.setValue`, honor `disabled`, and expose `error`. Do not keep a second authoritative state. The server still decides validation and fill allowlists.

## 3. Cover every projection

A form field type does not automatically create a detail entry, table cell, or filter. This example deliberately reuses `text-entry` and `number`; a genuinely new projection must register its matching maps with `registry.cell`, `registry.filter`, or another explicit registry method and emit matching server descriptors.

Unknown types do not crash the page, but their warning and telemetry are a deployment fault signal, not a fallback design. Version server and client halves together and install both before emitting the type.

## 4. Verify the contract

Test the observable round trip: descriptor type/props, create and edit hydration, validation rejection, disabled/hidden fill omission, persisted value, detail projection, and keyboard/screen-reader behavior. For reactive fields, run the server/client mirroring fixtures from `@adonia/core/testing`. The [accessibility report](/accessibility) defines the shared component obligations.

Built-in field pages under [Field types](/reference/fields) are generated from source TSDoc and the field roster; do not edit those generated pages. The complete public symbols are in the [API reference](/reference/api).

Source: https://adonia.pages.dev/guide/custom-fields/index.mdx
