docs/developer/dashboard/recipes/custom-form-field.mdx
Two ways to get a custom field onto the product form, both saved by the page's single Save button:
spree_products. You register the field and render its input; hydration and saving stay with the host form.Create a definition for products (Settings → Custom fields, the in-place "Set up" button on the Custom Fields card, or the API):
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.
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:
// 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.
Use this when your plugin adds an actual column and API attribute.
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). Read and write names must match.
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.
// 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 ?? '' },
],
},
})
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:
// 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>
)
}
// 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.
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.
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.
| 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 |
To also surface the attribute as a table column with filtering and sorting, continue with A product attribute, end to end.
useHostFormformFields registry