@mercurjs/docs 2.2.0-rc.1 → 2.2.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,306 @@
1
+ ---
2
+ title: "API routes"
3
+ description: "Thin HTTP adapters — typed request/response generics, Zod validation with createFindParams, filterable fields, middlewares as filters, and queryConfig."
4
+ ---
5
+
6
+ An API route is a thin adapter between HTTP and the rest of the system. Its whole job is: validate the request, run a [workflow](/rc/resources/best-practices/workflows) (for writes) or a [Query](/rc/resources/best-practices/workflows#the-query-engine) (for reads), and shape the response. No business logic lives here.
7
+
8
+ <Note>
9
+ Routes are [Medusa file-based API routes](https://docs.medusajs.com/learn/fundamentals/api-routes): a `route.ts` under `src/api/**` exports handlers named after HTTP verbs, and a sibling `middlewares.ts` wires validation and filters. Examples below use a custom **Brand** module exposed under `/admin/brands`.
10
+ </Note>
11
+
12
+ ## Type both the request and the response
13
+
14
+ Every handler is typed on **both** sides — mirror how Medusa's own routes are written:
15
+
16
+ - `AuthenticatedMedusaRequest<TBodyOrQuery>` — the generic is the validated **body** (writes) or **query params** (reads) type.
17
+ - `MedusaResponse<TResponse>` — the generic is the **response shape**, so `res.json(...)` is checked and the SDK infers a real return type instead of `unknown`.
18
+
19
+ ```ts title="src/api/admin/brands/route.ts"
20
+ import {
21
+ AuthenticatedMedusaRequest,
22
+ MedusaResponse,
23
+ } from "@medusajs/framework/http"
24
+ import { ContainerRegistrationKeys } from "@medusajs/framework/utils"
25
+
26
+ import { createBrandsWorkflow } from "../../../workflows/create-brands"
27
+ import {
28
+ AdminCreateBrandType,
29
+ AdminGetBrandsParamsType,
30
+ } from "./validators"
31
+ import { AdminBrandListResponse, AdminBrandResponse } from "./types"
32
+
33
+ export const GET = async (
34
+ req: AuthenticatedMedusaRequest<AdminGetBrandsParamsType>,
35
+ res: MedusaResponse<AdminBrandListResponse>
36
+ ) => {
37
+ const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
38
+
39
+ const { data: brands, metadata } = await query.graph({
40
+ entity: "brand",
41
+ fields: req.queryConfig.fields,
42
+ filters: req.filterableFields,
43
+ pagination: req.queryConfig.pagination,
44
+ })
45
+
46
+ res.json({
47
+ brands,
48
+ count: metadata!.count,
49
+ offset: metadata!.skip,
50
+ limit: metadata!.take,
51
+ })
52
+ }
53
+
54
+ export const POST = async (
55
+ req: AuthenticatedMedusaRequest<AdminCreateBrandType>,
56
+ res: MedusaResponse<AdminBrandResponse>
57
+ ) => {
58
+ const { result } = await createBrandsWorkflow(req.scope).run({
59
+ input: { brands: [req.validatedBody] },
60
+ })
61
+
62
+ res.json({ brand: result[0] })
63
+ }
64
+ ```
65
+
66
+ <Warning>
67
+ Don't leave `MedusaResponse` bare. An untyped response means `res.json({...})` accepts anything and the typed SDK resolves that endpoint to an empty/`unknown` response — the exact opposite of the point of the typed client. Always pass the response generic.
68
+ </Warning>
69
+
70
+ ## Only `GET`, `POST`, `DELETE`
71
+
72
+ <Warning>
73
+ Mercur routes use **only** `GET`, `POST`, and `DELETE`. There is no `PUT` or `PATCH` — model an update as a `POST` to the resource. Keeping to three verbs is what keeps the typed SDK (`.query` / `.mutate` / `.delete`) consistent across every route.
74
+ </Warning>
75
+
76
+ | Verb | Meaning | SDK method |
77
+ | --- | --- | --- |
78
+ | `GET` | Read (list or retrieve) | `.query()` |
79
+ | `POST` | Create **and** update | `.mutate()` |
80
+ | `DELETE` | Remove | `.delete()` |
81
+
82
+ ## Validation with Zod + exported types
83
+
84
+ Validation happens in `middlewares.ts` via `validateAndTransformBody` / `validateAndTransformQuery`, and every schema exports its inferred type so the handler generic and the SDK share one source of truth.
85
+
86
+ **Bodies** are plain Zod objects:
87
+
88
+ ```ts title="src/api/admin/brands/validators.ts"
89
+ import { z } from "zod"
90
+
91
+ export const AdminCreateBrand = z.object({
92
+ name: z.string(),
93
+ is_active: z.boolean().optional(),
94
+ })
95
+
96
+ export type AdminCreateBrandType = z.infer<typeof AdminCreateBrand>
97
+ ```
98
+
99
+ **List/read params** use the framework helpers — `createFindParams` (pagination + `fields` + `order`) and `createSelectParams` (retrieve) — rather than a hand-rolled object. This is what wires pagination and field selection consistently across every route:
100
+
101
+ ```ts title="src/api/admin/brands/validators.ts"
102
+ import { createFindParams, createOperatorMap } from "@medusajs/medusa/api/utils/validators"
103
+
104
+ export const AdminGetBrandsParams = createFindParams({
105
+ limit: 20,
106
+ offset: 0,
107
+ }).merge(
108
+ z.object({
109
+ // declare the fields that may be filtered on
110
+ id: z.union([z.string(), z.array(z.string())]).optional(),
111
+ name: z.string().optional(),
112
+ is_active: z.boolean().optional(),
113
+ created_at: createOperatorMap().optional(), // gt/lt/gte/lte ranges
114
+ })
115
+ )
116
+
117
+ export type AdminGetBrandsParamsType = z.infer<typeof AdminGetBrandsParams>
118
+ ```
119
+
120
+ The handler then trusts `req.validatedBody` / the validated query to already match those types — never re-validate inside the handler.
121
+
122
+ ## List vs retrieve
123
+
124
+ A list route (`GET /admin/brands`) and a retrieve route (`GET /admin/brands/:id`) select fields the same way but differ in their params helper and response shape. Retrieve uses `createSelectParams` (field selection only — no pagination or filters) and returns a single entity:
125
+
126
+ ```ts title="src/api/admin/brands/[id]/route.ts"
127
+ export const GET = async (
128
+ req: AuthenticatedMedusaRequest<AdminGetBrandParamsType>,
129
+ res: MedusaResponse<AdminBrandResponse>
130
+ ) => {
131
+ const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
132
+
133
+ const {
134
+ data: [brand],
135
+ } = await query.graph({
136
+ entity: "brand",
137
+ fields: req.queryConfig.fields,
138
+ filters: { id: req.params.id },
139
+ })
140
+
141
+ if (!brand) {
142
+ throw new MedusaError(MedusaError.Types.NOT_FOUND, `Brand ${req.params.id} not found`)
143
+ }
144
+
145
+ res.json({ brand })
146
+ }
147
+ ```
148
+
149
+ ```ts title="src/api/admin/brands/validators.ts — retrieve params"
150
+ import { createSelectParams } from "@medusajs/medusa/api/utils/validators"
151
+
152
+ export const AdminGetBrandParams = createSelectParams()
153
+ export type AdminGetBrandParamsType = z.infer<typeof AdminGetBrandParams>
154
+ ```
155
+
156
+ Both share the same `defaults` idea but declare them separately in the query config (`list` vs `retrieve`) — see [`queryConfig`](/rc/resources/best-practices/api-routes#queryconfig-and-field-selection) below.
157
+
158
+ ## Filterable fields
159
+
160
+ `req.filterableFields` is the **parsed, validated filter set** produced by `validateAndTransformQuery` from the query params above. Only fields your validator declares can appear there — an unknown query param is dropped, not passed through. The handler forwards it straight to Query:
161
+
162
+ ```ts
163
+ const { data: brands, metadata } = await query.graph({
164
+ entity: "brand",
165
+ fields: req.queryConfig.fields,
166
+ filters: req.filterableFields, // e.g. { is_active: true, created_at: { gt: ... } }
167
+ pagination: req.queryConfig.pagination,
168
+ })
169
+ ```
170
+
171
+ <Tip>
172
+ This is why filtering is declarative and safe: to make a field filterable you add it to the validator; to scope a request you inject onto `req.filterableFields` in middleware (next section). The handler never builds a `where` clause by hand.
173
+ </Tip>
174
+
175
+ ## Middlewares as filters
176
+
177
+ Middlewares aren't only for validation — they're where you inject **scoping filters** so handlers stay ignorant of the rule. A small middleware writes onto `req.filterableFields`; because the handler already forwards that to Query, the scope is applied without the handler knowing. For example, force `GET /admin/brands` to only ever return active rows:
178
+
179
+ ```ts title="src/api/admin/brands/middlewares.ts"
180
+ import {
181
+ MedusaRequest,
182
+ MedusaResponse,
183
+ MedusaNextFunction,
184
+ } from "@medusajs/framework/http"
185
+
186
+ const onlyActive = (
187
+ req: MedusaRequest,
188
+ _res: MedusaResponse,
189
+ next: MedusaNextFunction
190
+ ) => {
191
+ req.filterableFields.is_active = true // a column on the brand module itself
192
+ next()
193
+ }
194
+
195
+ export const adminBrandsMiddlewares = [
196
+ {
197
+ method: ["GET"],
198
+ matcher: "/admin/brands",
199
+ middlewares: [
200
+ validateAndTransformQuery(AdminGetBrandsParams, adminBrandQueryConfig.list),
201
+ onlyActive,
202
+ ],
203
+ },
204
+ ]
205
+ ```
206
+
207
+ <Tip>
208
+ Injecting a filter in middleware means a new route on the same resource is scoped by construction, not by remembering to add a filter. This works with `query.graph` because `is_active` lives on the **brand's own module**.
209
+ </Tip>
210
+
211
+ <Warning>
212
+ You can only filter this way on a field that belongs to the entity's **own module**. Filtering by a **linked** module's field (e.g. products by their `brand`) does **not** work with `query.graph` — Query aggregates modules after the fact, so there's no join to filter on. Cross-module filtering requires the [Index Module](/rc/resources/best-practices/module-links#filtering-by-a-linked-field--the-index-module) and `query.index`.
213
+ </Warning>
214
+
215
+ ## Trust the auth middleware
216
+
217
+ Authentication and actor resolution happen in middleware (`authenticate`), so by the time your handler runs the actor is already established — **trust it**. Read identity from the request context, never from the body:
218
+
219
+ ```ts
220
+ const userId = req.auth_context.actor_id // set by the authenticate middleware
221
+ ```
222
+
223
+ <Warning>
224
+ Don't re-derive or re-check identity inside handlers, and don't read user/owner ids from the request body — always take them from `req.auth_context` (or a context object a scoping middleware populated). Trusting the middleware keeps authorization in one place.
225
+ </Warning>
226
+
227
+ ## Vendor routes: `seller_context`
228
+
229
+ Every route under `/vendor/*` is **already authenticated and seller-scoped** — you don't wire auth yourself. By the time your handler runs, the caller is a verified seller member and the request carries a `req.seller_context` you can trust:
230
+
231
+ ```ts
232
+ export const POST = async (
233
+ req: AuthenticatedMedusaRequest<VendorCreateOfferType>,
234
+ res: MedusaResponse
235
+ ) => {
236
+ const sellerId = req.seller_context!.seller_id // the acting seller
237
+ const currency = req.seller_context!.currency_code
238
+ // ...run a workflow scoped to this seller
239
+ }
240
+ ```
241
+
242
+ `req.seller_context` gives you `seller_id`, `currency_code`, and the `seller_member` — all verified, so you never re-check membership in a handler.
243
+
244
+ <Warning>
245
+ Never take a `seller_id` from the request body or query to decide ownership — that's caller-supplied. The **only** authoritative seller is `req.seller_context.seller_id`. To scope a vendor list route to the caller's data, add the `filterBySellerId()` middleware and every query is constrained automatically — no per-handler `where`:
246
+
247
+ ```ts title="src/api/vendor/offers/middlewares.ts"
248
+ import { filterBySellerId } from "@mercurjs/core/..."
249
+
250
+ {
251
+ method: ["GET"],
252
+ matcher: "/vendor/offers",
253
+ middlewares: [
254
+ validateAndTransformQuery(VendorGetOffersParams, vendorOfferQueryConfig.list),
255
+ filterBySellerId(),
256
+ ],
257
+ }
258
+ ```
259
+ </Warning>
260
+
261
+ ## `queryConfig` and field selection
262
+
263
+ `validateAndTransformQuery` takes a query config that controls which `fields` are selectable, `isList`, and default pagination. The handler reads the resolved selection from `req.queryConfig.fields` and pagination from `req.queryConfig.pagination`.
264
+
265
+ ```ts title="src/api/admin/brands/query-config.ts"
266
+ export const adminBrandQueryConfig = {
267
+ list: {
268
+ defaults: ["id", "name", "is_active", "created_at"],
269
+ isList: true,
270
+ },
271
+ retrieve: {
272
+ defaults: ["id", "name", "is_active"],
273
+ },
274
+ }
275
+ ```
276
+
277
+ <Warning>
278
+ **`fields` replaces defaults unless prefixed.** An unprefixed field in the request's `fields` param *replaces* the route's default set; prefix with `+`/`-` to merge (e.g. `+brand.name`) or base fields like `thumbnail` silently drop. This is the `medusa-fields-param` gotcha.
279
+ </Warning>
280
+
281
+ ## Response types
282
+
283
+ Declare the response shapes next to the route (or in `@mercurjs/types` for shared ones) and use them as the `MedusaResponse` generic — the SDK reads these to type `.query()` / `.mutate()` returns:
284
+
285
+ ```ts title="src/api/admin/brands/types.ts"
286
+ import { PaginatedResponse } from "@medusajs/framework/types"
287
+
288
+ export interface AdminBrandResponse {
289
+ brand: BrandDTO
290
+ }
291
+
292
+ export type AdminBrandListResponse = PaginatedResponse<{
293
+ brands: BrandDTO[]
294
+ }>
295
+ ```
296
+
297
+ ## Checklist for a route
298
+
299
+ - Handler is thin: validate → run workflow (writes) or `query.graph` (reads) → respond.
300
+ - **Both** generics set: `AuthenticatedMedusaRequest<TBody|TQuery>` and `MedusaResponse<TResponse>` — never a bare `MedusaResponse`.
301
+ - Only `GET` / `POST` / `DELETE` exported; updates are `POST`.
302
+ - Query params built with `createFindParams` / `createSelectParams`; bodies with Zod; inferred types exported.
303
+ - Filterable fields declared in the validator; scoping injected via a `filterableFields` middleware, not inlined.
304
+ - Identity read from `req.auth_context`, never the body. On vendor routes, the authoritative seller is `req.seller_context.seller_id` (set by `ensureSellerMiddleware`); scope reads with `filterBySellerId()`.
305
+ - `fields` prefixed with `+`/`-` to merge; defaults declared in `queryConfig`.
306
+ - No mutations outside a workflow.
@@ -0,0 +1,241 @@
1
+ ---
2
+ title: "Custom fields"
3
+ description: "The full loop — add a custom field in core, render it in the panels with defineCustomFieldsConfig, pull linked data with the link property, and type it end-to-end."
4
+ ---
5
+
6
+ Custom Fields attach extra data to an existing entity (a product, customer, order…) through configuration — no hand-written model or migration. What makes them powerful is the **full loop across the stack**: you declare the field in core, render it in the panels with `defineCustomFieldsConfig`, optionally pull in [linked-module](/rc/resources/best-practices/module-links) data with the `link` property, and [type it end-to-end](/rc/resources/best-practices/types) so every SDK call carries it.
7
+
8
+ This page is the best-practices view. For the full storage-side setup see [Custom Fields](/rc/resources/customization/custom-fields).
9
+
10
+ ## Reach for a custom field vs a module
11
+
12
+ The decision is about the **shape and lifecycle** of the data, not its size.
13
+
14
+ <CardGroup cols={2}>
15
+ <Card title="Use a custom field when…" icon="circle-check">
16
+ 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.
17
+ </Card>
18
+ <Card title="Build a module when…" icon="cube">
19
+ 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.
20
+ </Card>
21
+ </CardGroup>
22
+
23
+ <Warning>
24
+ Custom Fields is strictly **one row per parent entity**. Forcing a one-to-many or stateful concept into it works until you need a second row or a state transition. If in doubt, model it as a [module](/rc/resources/best-practices/modules).
25
+ </Warning>
26
+
27
+ ## The full loop
28
+
29
+ ### 1. Declare the field in core
30
+
31
+ Register the Custom Fields module and describe the field in `medusa-config.ts`. The module generates the side table, the link, and the schema on `db:migrate`:
32
+
33
+ ```ts title="medusa-config.ts"
34
+ {
35
+ resolve: "@mercurjs/core/modules/custom-fields",
36
+ options: {
37
+ customFields: {
38
+ Product: {
39
+ is_featured: { type: "boolean", nullable: true },
40
+ },
41
+ },
42
+ },
43
+ }
44
+ ```
45
+
46
+ The value now lives in the module's own `custom_fields` side table — 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](/rc/resources/best-practices/module-links#filtering-by-a-linked-field--the-index-module), not `query.graph`.)
47
+
48
+ <Warning>
49
+ **Prefer the `custom_fields` link over stuffing values into `metadata`.** `metadata` is an untyped JSON bag with no schema, no queryable columns, and no clean extension point — it turns into a dumping ground. The Custom Fields module gives you a real linked table (`custom_fields.*`) with typed columns you can read and (via the Index Module) filter on, while still being config-only. Reach for `metadata` only for genuinely throwaway, never-queried scratch data.
50
+ </Warning>
51
+
52
+ <Tip>
53
+ Mutations still go through workflows. The panel submits custom-field values under **`additional_data`** on the parent's create/update route — and `additional_data` is exactly what [workflow hooks](/rc/resources/best-practices/workflows#hooks--let-others-extend-your-workflow) receive. So the same values you enter in the panel can be consumed by a `productsCreated` / `productUpdated` hook to run follow-up logic, persist to the linked table, or trigger side effects. Never write a custom-field value with a direct route write.
54
+ </Tip>
55
+
56
+ ### 2. Render it in the panel with `defineCustomFieldsConfig`
57
+
58
+ 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:
59
+
60
+ ```tsx title="apps/vendor/src/custom-fields/product.tsx"
61
+ import { defineCustomFieldsConfig } from "@mercurjs/dashboard-sdk"
62
+ import { createFormHelper } from "@mercurjs/dashboard-shared"
63
+
64
+ type ProductWithCustomFields = { custom_fields?: { is_featured?: boolean } }
65
+
66
+ const form = createFormHelper<ProductWithCustomFields>()
67
+
68
+ export default defineCustomFieldsConfig({
69
+ model: "product",
70
+ link: "custom_fields", // fetch custom_fields.* with the product — no hand-written field list
71
+ forms: [
72
+ {
73
+ zone: "edit",
74
+ fields: {
75
+ is_featured: form.define({
76
+ validation: form.boolean().optional(),
77
+ label: "Featured",
78
+ // read the current value from the linked table, not metadata
79
+ defaultValue: (data) => Boolean(data?.custom_fields?.is_featured),
80
+ }),
81
+ },
82
+ },
83
+ ],
84
+ displays: [
85
+ {
86
+ zone: "general",
87
+ fields: [
88
+ {
89
+ id: "is_featured",
90
+ component: ({ data }) => (data.custom_fields?.is_featured ? "Featured" : "—"),
91
+ },
92
+ ],
93
+ },
94
+ ],
95
+ })
96
+ ```
97
+
98
+ <Note>
99
+ 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.
100
+ </Note>
101
+
102
+ The `displays` fields follow an add / replace / remove convention keyed by `id`:
103
+
104
+ - **unknown id** → appends a new read-only row,
105
+ - **built-in id + component** → replaces that field's render,
106
+ - **built-in id + `component: null`** → hides the field.
107
+
108
+ ## The extension API `link` property
109
+
110
+ 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 (e.g. a `brand`) in the product's columns and displays without wiring a second query:
111
+
112
+ ```tsx title="apps/vendor/src/custom-fields/product.tsx"
113
+ export default defineCustomFieldsConfig({
114
+ model: "product",
115
+ link: "brand", // fetch brand.* with each product — one or an array of links
116
+ list: {
117
+ columns: [
118
+ // linked data is available on the row, no extra fetch
119
+ { id: "brand_name", header: "Brand", component: ({ row }) => row.brand?.name },
120
+ ],
121
+ },
122
+ displays: [
123
+ {
124
+ zone: "general",
125
+ fields: [{ id: "brand", component: ({ data }) => data.brand?.name ?? "-" }],
126
+ },
127
+ ],
128
+ })
129
+ ```
130
+
131
+ <Tip>
132
+ `link` replaces the old "remember to add the fields to every fetch" chore. Under the hood the panel reads the registry's links (`getLinks(model)`) and merges them into the built-in list, detail, and edit fetches with `withLinkFields(fields, links)` (`+brand.*`) — so the linked data is present in **all three** places automatically. There's no `extendFields`: declaring the `link` is what makes its fields available to both columns and displays.
133
+ </Tip>
134
+
135
+ <Warning>
136
+ The link must actually exist as a [module link](/rc/resources/best-practices/module-links) and, in the vendor panel, respect the curated-field constraint — the fetch derived from `link` runs against the vendor product query, which rejects arbitrary `*`-relation overrides. Declare the link, then reference only its real fields.
137
+ </Warning>
138
+
139
+ ## 3. Type it end-to-end
140
+
141
+ The rendered value comes back from the API, but the panel's `ProductDTO` doesn't know about `is_featured` yet. Close the gap with a one-line declaration-merging `.d.ts` so **every** SDK endpoint is typed — no per-call casts:
142
+
143
+ ```ts title="apps/vendor/src/types/custom-fields.d.ts"
144
+ import "@medusajs/types"
145
+
146
+ declare module "@medusajs/types" {
147
+ interface ProductDTO {
148
+ custom_fields?: { is_featured?: boolean }
149
+ }
150
+ }
151
+ ```
152
+
153
+ Now `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](/rc/resources/best-practices/types#the-scenario-a-custom-field-typed-end-to-end).
154
+
155
+ ## The full override flow: `additional_data` → route → workflow hook
156
+
157
+ 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.
158
+
159
+ The flow has three links in the chain.
160
+
161
+ ### 1. The panel submits under `additional_data`
162
+
163
+ 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.
164
+
165
+ ### 2. The route accepts it via `additionalDataValidator`
166
+
167
+ 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 — no need to touch the route handler:
168
+
169
+ ```ts title="src/api/middlewares.ts"
170
+ import { defineMiddlewares } from "@medusajs/framework/http"
171
+ import { z } from "@medusajs/framework/zod"
172
+
173
+ export default defineMiddlewares({
174
+ routes: [
175
+ {
176
+ method: "POST",
177
+ matcher: "/vendor/products",
178
+ additionalDataValidator: {
179
+ brand_id: z.string().optional(),
180
+ },
181
+ },
182
+ ],
183
+ })
184
+ ```
185
+
186
+ ### 3. A workflow hook consumes it
187
+
188
+ Mercur's product create workflow — `createProductsWorkflow` from `@mercurjs/core/workflows` (id `mercur-create-products`) — 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](/rc/resources/best-practices/module-links) the product to a brand — with a compensation function so a failure rolls the link back:
189
+
190
+ ```ts title="src/workflows/hooks/created-product.ts"
191
+ import { createProductsWorkflow } from "@mercurjs/core/workflows"
192
+ import { StepResponse } from "@medusajs/framework/workflows-sdk"
193
+ import { Modules } from "@medusajs/framework/utils"
194
+ import { LinkDefinition } from "@medusajs/framework/types"
195
+ import { BRAND_MODULE } from "../../modules/brand"
196
+
197
+ createProductsWorkflow.hooks.productsCreated(
198
+ async ({ products, additional_data }, { container }) => {
199
+ if (!additional_data?.brand_id) {
200
+ return new StepResponse([], [])
201
+ }
202
+
203
+ const link = container.resolve("link")
204
+ const links: LinkDefinition[] = products.map((product) => ({
205
+ [Modules.PRODUCT]: { product_id: product.id },
206
+ [BRAND_MODULE]: { brand_id: additional_data.brand_id },
207
+ }))
208
+
209
+ await link.create(links)
210
+ return new StepResponse(links, links)
211
+ },
212
+ // compensation — undo the links if a later step fails
213
+ async (links, { container }) => {
214
+ if (!links?.length) {
215
+ return
216
+ }
217
+ await container.resolve("link").dismiss(links)
218
+ }
219
+ )
220
+ ```
221
+
222
+ <Note>
223
+ 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.
224
+ </Note>
225
+
226
+ <Tip>
227
+ This is the **override pattern** in one sentence: the panel writes to `additional_data`, the route lets it through via `additionalDataValidator`, and a `hooks.<name>` consumer turns it into real behaviour — all additively, without copying or replacing any built-in code. It's how you extend a Mercur (or Medusa) flow instead of forking it. See [Workflows → hooks](/rc/resources/best-practices/workflows#hooks--let-others-extend-your-workflow).
228
+ </Tip>
229
+
230
+ <Warning>
231
+ The hook runs **inside** the workflow, so its mutation still obeys the [one-mutation-per-step + compensation](/rc/resources/best-practices/workflows#one-mutation-per-step--compensation) rule — always pair `link.create` with a `link.dismiss` compensation. Never do the work in a route handler after the workflow returns; put it in the hook.
232
+ </Warning>
233
+
234
+ ## Checklist
235
+
236
+ - Data is genuinely one-row-per-parent with no lifecycle → custom field; otherwise a module.
237
+ - Field registered in `medusa-config.ts`; `db:migrate` run; writes go through `additional_data` on a workflow.
238
+ - Panel: one `defineCustomFieldsConfig` per model contributes forms / displays / list, with typed `zone`s.
239
+ - Linked-module data pulled in with the `link` property (not a hand-written second fetch); the link exists and respects vendor field constraints.
240
+ - Extended fields typed once via a `.d.ts` merging into the framework DTO, and requested with `+…*` so they arrive.
241
+ - The override chain is complete: panel → `additional_data` → `additionalDataValidator` declares the key → a `hooks.<name>` consumer does the work inside the workflow, with compensation. No route/workflow forked.