Skip to main content
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):
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:
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

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.

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.

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:
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?

SituationPath
Merchant-defined attributes, plugin data without schema migrationsA — definition, zero code
Default widget is fineA
A real column with Rails validations, queries, or indexesB
The field belongs visually inside your own card with other UIB

Going further

To also surface the attribute as a table column with filtering and sorting, continue with A product attribute, end to end.

Reference