defineCustomFieldsConfig, optionally pull in linked-module data with the link property, and type it end-to-end so every SDK call carries it.
This page is the best-practices view. For the full storage-side setup see Custom Fields.
Reach for a custom field vs a module
The decision is about the shape and lifecycle of the data, not its size.Use a custom field when…
The data is a plain property of one existing record:
is_featured on a product, tier on a customer, source on an order. One row per parent, no lifecycle of its own, read alongside the parent.Build a module when…
The data has its own lifecycle, relates to more than one entity, has many rows per parent, or carries business logic and its own routes. Reviews, tickets, subscriptions.
The full loop
1. Declare the field in core
Register the Custom Fields module and describe the field inmedusa-config.ts. The module generates the side table, the link, and the schema on db:migrate.
medusa-config.ts
custom_fields side table. It is linked to the product, readable alongside it through query.graph, and written through additional_data on the entity’s create/update route. Filtering products by a custom-field value is cross-module and needs the Index Module, not query.graph.
2. Render it in the panel with defineCustomFieldsConfig
Drop one file per model under the panel’s src/custom-fields/. A single config contributes form fields (edit drawer, submitted under additional_data), read-only displays (detail sections), and list columns. Declare link: "custom_fields" so the module’s data is fetched alongside the product and available to your fields and displays.
apps/vendor/src/custom-fields/product.tsx
The
zone values are typed. The panel’s codegen scans the host <FormExtensionZone> / <DisplayExtensionZone> usages and emits the valid zones per model into extension-targets.d.ts. zone: "nope" fails tsc. You don’t hand-maintain that list.displays fields follow an add / replace / remove convention keyed by id:
- Unknown id: appends a new read-only row.
- Built-in id with component: replaces that field’s render.
- Built-in id with
component: null: hides the field.
The extension API link property
A custom-field config can also declare module links to fetch alongside the entity with the link property. This is how you surface data from a linked module such as a brand in the product’s columns and displays without wiring a second query.
apps/vendor/src/custom-fields/product.tsx
3. Type it end-to-end
The rendered value comes back from the API, but the panel’sProductDTO doesn’t know about is_featured yet. Close the gap with a one-line declaration-merging .d.ts so every SDK endpoint is typed, with no per-call casts.
apps/vendor/src/types/custom-fields.d.ts
product.custom_fields?.is_featured is typed on every sdk.vendor.products.* response. The runtime value is delivered by the link / registry merge above, not a hand-added +field.* (the vendor product query rejects arbitrary *-relation overrides). The mechanics, why merging into the upstream interface flows through, are covered in Types & augmentation.
The full override flow: additional_data → route → workflow hook
Rendering and typing a field is only half the story. The reason custom fields submit under additional_data is that it’s the framework’s built-in extension channel: values entered in the panel travel through the entity’s existing API route into the workflow’s hooks, where your own code consumes them, without forking the route or the workflow. This is exactly what a Mercur override looks like.
The flow has three links in the chain.
1
The panel submits under additional_data
You already did this. A
defineCustomFieldsConfig edit/create field is submitted as additional_data.<field> on the entity’s create/update request. Nothing else to wire on the frontend.2
The route accepts it via additionalDataValidator
The vendor/admin product routes accept an
additional_data body param, but each key must be declared or it’s rejected. Register the allowed keys with additionalDataValidator in a middleware, with no need to touch the route handler.src/api/middlewares.ts
3
A workflow hook consumes it
Mercur’s product create workflow is
createProductsWorkflow from @mercurjs/core/workflows (id mercur-create-products). It is what the vendor route runs, and it exposes a productsCreated hook that runs after the products are created, receiving both the created records and your additional_data. Consume it to perform the real work, here linking the product to a brand, with a compensation function so a failure rolls the link back.src/workflows/hooks/created-product.ts
Mercur’s
createProductsWorkflow wraps Medusa’s stock create-products flow and adds the marketplace layer (seller association, attributes, audit trail). Because it re-exposes the validate and productsCreated hooks, you extend the Mercur flow the same way you would a plain Medusa one. Consume its hook, don’t fork it.Checklist
- Data is genuinely one row per parent with no lifecycle: use a custom field, otherwise a module.
- Field registered in
medusa-config.ts,db:migraterun, writes go throughadditional_dataon a workflow. - Panel: one
defineCustomFieldsConfigper model contributes forms, displays, and list, with typedzones. - Linked-module data pulled in with the
linkproperty (not a hand-written second fetch), the link exists and respects vendor field constraints. - Extended fields typed once via a
.d.tsmerging into the framework DTO, and requested with+…*so they arrive. - The override chain is complete: panel, then
additional_data, thenadditionalDataValidatordeclares the key, then ahooks.<name>consumer does the work inside the workflow, with compensation. No route or workflow forked.
Next steps
Module links
Relate modules and fetch linked data alongside an entity.
Modules
Model data that has its own lifecycle, rows, and routes.
Workflows & hooks
Extend a built-in flow through its hooks instead of forking it.
Types & augmentation
Type a custom field end-to-end with declaration merging.