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

# Add a custom field to the product form

> Add a field to the built-in product form that hydrates from the API and saves with the form's own Save button — no save logic of your own.

Two ways to get a custom field onto the product form, both saved by the page's single Save button:

* **Custom field definition (no code)** — declare the field as data; the dashboard renders it. Right for merchant-managed attributes and most plugin data.
* **Extension form field (code)** — for a real database column your plugin added to `spree_products`. You register the field and render its input; hydration and saving stay with the host form.

## Path A: custom field definition — no code

Create a definition for products (Settings → Custom fields, the in-place "Set up" button on the Custom Fields card, or the API):

```bash theme={"theme":"night-owl"}
npx spree api post custom_field_definitions --data '{
  "resource_type": "Spree::Product",
  "namespace": "specs",
  "key": "tech_specs",
  "label": "Technical specifications",
  "field_type": "long_text"
}'
```

That's the whole feature. The **Custom Fields card** on the product form now renders a textarea for it; the value rides the form's `custom_fields[]` and persists when the merchant hits **Save**. Field types: `short_text`, `long_text`, `rich_text`, `number`, `boolean`, `json`.

### Optional: replace the input widget

When the default widget isn't right — a color, a rating, a structured pair — register a component for that specific definition, keyed by its `namespace.key`:

```tsx theme={"theme":"night-owl"}
// src/plugins.ts (or a plugin's entry module)
import { type CustomFieldComponentProps, defineDashboardPlugin } from '@spree/dashboard-core'

function ColorPicker({ id, value, onChange, ariaLabel }: CustomFieldComponentProps) {
  return (
    <input
      id={id}
      type="color"
      aria-label={ariaLabel}
      value={(value as string) || '#000000'}
      onChange={(e) => onChange(e.target.value)}
    />
  )
}

defineDashboardPlugin({
  customFieldComponents: {
    'specs.color': ColorPicker,
  },
})
```

The component is a controlled input — render `value`, call `onChange`. Persistence stays with the card (the form's Save on products and categories; the card's own Save on orders and customers). When no component is registered, the default widget for the definition's `field_type` renders.

## Path B: extension form field — a real column

Use this when your plugin adds an actual column and API attribute.

### 1. Backend

```ruby theme={"theme":"night-owl"}
class AddTechSpecsToSpreeProducts < ActiveRecord::Migration[7.2]
  def change
    add_column :spree_products, :tech_specs, :text, null: false, default: ''
  end
end
```

Expose it on the Admin API — the serializer returns `tech_specs`, and the products controller permits it on write (see [Backend integration](/developer/dashboard/customization/backend)). Read and write names must match.

### 2. Register the field

Tell the product form about the field: where its value comes from on load. Everything else — dirty tracking, the PATCH payload, re-baselining after save — is the host form's job.

```tsx theme={"theme":"night-owl"}
// src/plugins.ts (or a plugin's entry module)
import { defineDashboardPlugin } from '@spree/dashboard-core'

defineDashboardPlugin({
  formFields: {
    product: [
      // `from` receives the fetched product — or null on the create form.
      { name: 'tech_specs', from: (product) => product?.tech_specs ?? '' },
    ],
  },
})
```

### 3. Render the input from a slot

The `product.form_sidebar` slot renders inside the product form. Bind your input to the host form with `useHostForm()` — no `<form>` of your own, no save button:

```tsx theme={"theme":"night-owl"}
// src/fields/tech-specs-card.tsx
import { useHostForm } from '@spree/dashboard-core'
import {
  Card, CardContent, CardHeader, CardTitle,
  Field, FieldError, FieldLabel, Textarea,
} from '@spree/dashboard-ui'
import { useTranslation } from 'react-i18next'

export function TechSpecsCard() {
  const { t } = useTranslation()
  const form = useHostForm<{ tech_specs: string }>()
  const error = form.formState.errors.tech_specs?.message

  return (
    <Card>
      <CardHeader>
        <CardTitle>{t('admin.fields.product.tech_specs.label')}</CardTitle>
      </CardHeader>
      <CardContent>
        <Field>
          <FieldLabel className="sr-only" htmlFor="tech_specs">
            {t('admin.fields.product.tech_specs.label')}
          </FieldLabel>
          <Textarea
            id="tech_specs"
            rows={6}
            placeholder={t('admin.fields.product.tech_specs.placeholder')}
            aria-invalid={Boolean(error)}
            {...form.register('tech_specs')}
          />
          {error && <FieldError>{error as string}</FieldError>}
        </Field>
      </CardContent>
    </Card>
  )
}
```

```tsx theme={"theme":"night-owl"}
// src/plugins.ts — alongside the formFields registration
defineDashboardPlugin({
  formFields: {
    product: [{ name: 'tech_specs', from: (product) => product?.tech_specs ?? '' }],
  },
  slots: {
    'product.form_sidebar': [{ id: 'tech-specs', component: TechSpecsCard as never, position: 80 }],
  },
})
```

Done. The field hydrates when the page loads, participates in the form's dirty state (Save enables when it changes), ships in the same PATCH as every core field, and re-baselines after save.

### Validation

Extension fields are validated by the **server** — a Rails validation failing returns a 422, and the host form's error mapping puts the message inline on your field (that's the `errors.tech_specs` read in the component). This mirrors how Vendure and Saleor treat extension-field validation: the backend is authoritative; the client renders what it says.

### Which forms support this

`useHostForm()` works wherever a built-in form exposes its form context — currently the **product** (edit + new), **category** (edit + new), and **store settings** forms, with matching slots (`product.form_sidebar`, `category.form_sidebar`, `store.form_main`) and form keys (`product`, `category`, `store`). Orders and customers have no page-wide form — their sheets own their edits — so slot widgets there manage their own persistence; use `useOptionalHostForm()` to write a widget that adapts to both contexts.

## Which path should I pick?

| Situation                                                          | Path                          |
| ------------------------------------------------------------------ | ----------------------------- |
| Merchant-defined attributes, plugin data without schema migrations | **A** — definition, zero code |
| Default widget is fine                                             | **A**                         |
| A real column with Rails validations, queries, or indexes          | **B**                         |
| The field belongs visually inside your own card with other UI      | **B**                         |

## Going further

To also surface the attribute as a **table column with filtering and sorting**, continue with [A product attribute, end to end](/developer/dashboard/recipes/attribute-end-to-end).

## Reference

* [Custom fields](/developer/customization/metadata) — definitions, types, the API
* [Slots](/developer/dashboard/customization/slots) — the injection mechanism
* [`useHostForm`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/hooks/use-host-form.ts)
* [`formFields` registry](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/lib/form-fields-registry.ts)
