Skip to main content
defineCustomFieldsConfig is Mercur’s model-scoped extension surface: from one file per model, you add validated fields to built-in create/edit forms, replace/remove/add fields in detail sections, and add columns to the list table — all wired into the built-in page, all typed against the model’s generated registry.
UI, not schema. This helper is a panel surface — it renders, validates, and displays fields. It does not create database columns. To store extra data, use the backend Custom Fields module or your own API route/workflow. In the MVP, panel custom fields for product are submitted under additional_data and persisted onto the product’s metadata.

What you’ll build

An ERP ID field on the vendor product edit form, shown in the product’s detail section and as a list-table column — from a single src/custom-fields/product.tsx.

Register the typed targets (once)

Models, form zones, display zones, and built-in field ids are typed per panel. Register them once (shipped by create-mercur-app):
apps/vendor/src/extension-targets.d.ts
/// <reference types="@mercurjs/vendor/extension-targets" />

Build the config

1

Create the model file

Drop src/custom-fields/<model>.tsx and default-export a defineCustomFieldsConfig. createFormHelper (from @mercurjs/dashboard-shared) turns a Zod schema into an input type + validation:
apps/vendor/src/custom-fields/product.tsx
import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk"
import { createFormHelper } from "@mercurjs/dashboard-shared"

type ProductWithMeta = { metadata?: Record<string, unknown> }
const form = createFormHelper<ProductWithMeta>()

export default defineCustomFieldsConfig({
  model: "product",
  forms: [
    {
      zone: "edit",
      fields: {
        erp_id: form.define({
          validation: form.string().optional(),
          label: "ERP ID",
          description: "External system identifier",
          placeholder: "ERP-000",
          defaultValue: (data) => (data?.metadata?.erp_id as string) ?? "",
        }),
      },
    },
  ],
})
2

Add a detail-section display

displays[] targets a detail-page section by its zone id. Keyed by field id, an entry adds, replaces, or removes a field:
import { Text } from "@medusajs/ui"

// ...inside defineCustomFieldsConfig:
displays: [
  {
    zone: "general",
    fields: [
      // ADD — an unknown id appends a new read-only row
      {
        id: "erp_id",
        component: ({ data }) => (
          <Text size="small" className="text-ui-fg-subtle px-6 py-4">
            ERP ID: {String(data?.metadata?.erp_id ?? "-")}
          </Text>
        ),
      },
      // REMOVE — a built-in id + null hides the field
      { id: "subtitle", component: null },
      // REPLACE — a built-in id + component overrides its render
      {
        id: "handle",
        component: ({ data }) => (
          <Text size="small" className="text-ui-fg-subtle px-6 py-4">
            /{(data as { handle?: string })?.handle}
          </Text>
        ),
      },
    ],
  },
],
Built-in field ids (like subtitle, handle, status, title) autocomplete from the panel’s generated CustomFieldsRegistry; an unknown id is treated as an added row.
3

Add a list column

The list block extends the model’s list table — add or override columns by id, hide built-in columns, and reorder:
// ...inside defineCustomFieldsConfig:
list: {
  columns: [
    {
      id: "erp_id",
      header: "ERP",
      component: ({ row }) => String(row.metadata?.erp_id ?? "-"),
    },
  ],
  viewDefaults: {
    columnVisibility: { collection: false }, // hide a built-in column
    columnOrder: ["product", "erp_id", "status"], // reorder
  },
},
4

Reload the panel

Open the vendor portal. The product edit drawer shows the ERP ID field (validated on submit and persisted via additional_data), the detail general section shows the ERP ID row (with subtitle removed and handle re-rendered), and the product list shows the ERP column.

The createFormHelper surface

createFormHelper<T>() exposes a Zod-based surface that drives both the input type and its validation:
const form = createFormHelper<T>()

form.define({
  validation: form.string().min(1),   // string | number | boolean | date | array | object | null | nullable | coerce
  label: "…",
  description: "…",
  placeholder: "…",
  defaultValue: "" | ((data) => /* derive from the entity */),
  component,                            // optional custom render component
})
Fields render through the standard Form.Field → Form.Item chain (never a raw Controller) and participate in the existing TabbedForm / RouteDrawer submit and validation flow.

Linked-module data

To read data from a linked module alongside the entity, declare it with link; those relations are fetched with the entity and become available to columns and displays:
export default defineCustomFieldsConfig({
  model: "product",
  link: "brand", // or ["brand", "warranty"]
  list: {
    columns: [{ id: "brand_name", header: "Brand", component: ({ row }) => row.brand?.name }],
  },
})
The SDK derives the fetch query from link and merges it into the built-in query with the +/- convention — you never hand-write the field list.

Verify

  1. The ERP ID field renders in the product edit drawer and validates on submit.
  2. Saving persists the value (visible on reload) via additional_datametadata.
  3. The detail general section shows the ERP row, hides subtitle, and re-renders handle.
  4. The product list shows the ERP column, hides collection, and reorders columns.
  5. Set zone: "nope" in formsbun run lint (tsc) fails against the model’s registry.

FAQ

Today: the product model in the vendor portal, with form zone edit and display zone general. The valid set per panel is generated into CustomFieldsRegistry in extension-targets.d.ts — autocomplete model / zone to see what’s mounted.
In the MVP, product custom fields are submitted under additional_data and persisted onto product.metadata. defineCustomFieldsConfig itself doesn’t create a column — for durable, queryable storage model it with the backend Custom Fields module or a custom route/workflow.
That’s the same helper with zone: "onboarding" and tab set to a wizard step id (vendor only). It’s designed but not mounted in the current MVP — the runtime host exists; the wizard mount is a follow-up.
Yes — a block can include src/custom-fields/ files in its vendor_ui / admin_ui entry, aggregated like the host app’s.

Next steps

Custom Fields module

Persist extra data on an entity with a generated side table.

Add a widget

Inject a component at a built-in zone.