medusa-dynamic-metadata
v0.0.14
Published
A starter for Medusa plugins.
Maintainers
Readme
medusa-dynamic-metadata
Configuration-driven Medusa v2 plugin for rendering and persisting dynamic metadata fields across admin entity detail pages.
Plugin Overview
medusa-dynamic-metadata lets you define metadata descriptors per entity in plugin options, then automatically renders a typed metadata editor in Medusa Admin widgets for those entity detail pages.
What It Does
- Resolves metadata configuration from
medusa-config.ts(entitiesmap). - Detects entity type in admin widgets (zone first, data-shape fallback).
- Renders a universal metadata form supporting 27 field types (
text,number,select,json,relation, media URL/file upload, etc.). - Validates descriptor-driven constraints (
required,validation.min/max/regex, options validation). - Saves metadata to entity-specific admin APIs (or custom endpoint override).
- Provides admin API for retrieving resolved descriptors by entity.
Problem It Solves
It removes the need to hand-build custom metadata UI and persistence logic per entity. Teams can add/modify metadata fields through plugin configuration instead of repeated frontend/backend boilerplate.
Medusa Version
Built for Medusa v2 (@medusajs/framework / @medusajs/medusa 2.12.3).
Installation & Setup
Install
npm install medusa-dynamic-metadata
# or
yarn add medusa-dynamic-metadataRegister in medusa-config.ts
import { defineConfig } from "@medusajs/framework"
export default defineConfig({
plugins: [
{
resolve: "medusa-dynamic-metadata",
options: {
entities: {
products: {
descriptors: [
{ key: "brand", label: "Brand", type: "text", filterable: true },
{ key: "warranty_years", label: "Warranty (Years)", type: "number" },
],
},
},
},
},
],
})Database Migrations
No custom module/entity migrations are present in this plugin source. It uses existing Medusa entity metadata columns and admin endpoints.
Configuration (config.ts / plugin options)
Defined in src/config/metadata-options.ts.
Root Options
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| entities | Record<string, EntityMetadataConfig> | Optional | {} | Per-entity metadata configuration keyed by entity type (for example products, orders). |
Entity Metadata Config
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
| descriptors | MetadataDescriptor[] | Optional | [] | Field definitions to render and persist. |
| expose_client_helpers | boolean | Optional | false | Flag stored in normalized config (helper exposure toggle). |
| filterable | boolean | Optional | false | Entity-level filterability flag in config. |
| widget_zone | string | Optional | Auto-resolved from entity mapping or {singular}.details.after | Admin widget zone override. |
| api_endpoint | string | Optional | Derived from entity registry | Admin update endpoint override for saving metadata. |
Metadata Descriptor Fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| key | string | Yes | - | Metadata key on entity. |
| type | MetadataFieldType | Yes | - | Input type and coercion/validation behavior. |
| label | string | Optional | key | Display label. |
| filterable | boolean | Optional | false | Per-field filterable marker. |
| required | boolean | Optional | false | Required validation. |
| default_value | unknown | Optional | undefined | Default form value when metadata is missing. |
| validation | { min?: number; max?: number; regex?: string } | Optional | undefined | Numeric/regex constraints. |
| options | { value: string; label?: string }[] | Optional | undefined | Used for select, multiselect, radio, checkbox. |
Supported MetadataFieldType Values
text, textarea, richtext, markdown, number, integer, float, bool, date, time, datetime, select, multiselect, radio, checkbox, image, video, audio, file, url, email, phone, color, json, object, array, relation
Complete Example Config
{
resolve: "medusa-dynamic-metadata",
options: {
entities: {
products: {
descriptors: [
{ key: "brand", label: "Brand", type: "text", filterable: true, required: true },
{ key: "warranty_years", label: "Warranty (Years)", type: "integer", validation: { min: 0, max: 20 } },
{
key: "status",
label: "Status",
type: "select",
options: [
{ value: "draft", label: "Draft" },
{ value: "active", label: "Active" },
],
},
{ key: "care_guide", label: "Care Guide", type: "file" },
{ key: "specs", label: "Specs", type: "json" },
],
expose_client_helpers: false,
filterable: true,
widget_zone: "product.details.after",
api_endpoint: "/admin/products/{id}",
},
orders: {
descriptors: [
{ key: "source", label: "Source", type: "text" },
{ key: "priority", label: "Priority", type: "radio", options: [{ value: "normal" }, { value: "high" }] },
],
},
},
},
}Environment Variables
Environment variable usage found in plugin source:
| Variable | Where | Purpose | Required | Example |
|---|---|---|---|---|
| NODE_ENV | src/admin/components/universal-metadata-widget.tsx | Enables/disables debug/warn logs in non-production mode. | Optional | production |
⚠️ Note:
src/modules/README.mdcontains example text referencingprocess.env.API_KEY, but it is documentation content, not runtime plugin code.
REST APIs / Routes
1) GET /admin/metadata-config
- Auth: Admin (admin route namespace)
- Query params:
entity(string, optional, default:"products")
- Response:
{ "metadataDescriptors": [ { "key": "brand", "type": "text", "label": "Brand" } ] } - Behavior:
- Reads plugin config with
resolveDynamicMetadataOptions. - Returns empty descriptor array when entity is not configured (no 404).
- Reads plugin config with
2) GET /store/metadata-config
- Auth: Storefront (store route namespace)
- Query params:
entity(string, optional)
- Response (when
entityis provided and exposed):{ "metadataDescriptors": [ { "key": "brand", "type": "text", "label": "Brand" } ] } - Response (when
entityis omitted):{ "metadataDescriptorsByEntity": { "products": [ { "key": "brand", "type": "text", "label": "Brand" } ] } } - Behavior:
- Returns only entities with
expose_client_helpers: true. - Returns empty descriptor array when the requested entity is hidden/not configured.
- Returns only entities with
3) POST /admin/product-variants/:id
- Auth: Admin (admin route namespace)
- Path params:
id(string, required): product variant ID.
- Body: passthrough payload with optional
metadataobject plus other variant update fields. - Response:
{ "product_variant": { "id": "variant_...", "metadata": {} } } - Behavior:
- Executes
updateProductVariantsWorkflowwith selector by ID and update payload. - Returns first updated variant as
product_variant.
- Executes
Important Endpoint Examples
# Get metadata descriptors for products
curl -X GET "http://localhost:9000/admin/metadata-config?entity=products" \
-H "Authorization: Bearer <admin_token>"# Update metadata on a product variant
curl -X POST "http://localhost:9000/admin/product-variants/variant_123" \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{
"metadata": {
"fabric": "cotton",
"is_limited": true
}
}'// fetch example for metadata config
const res = await fetch("/admin/metadata-config?entity=orders", {
credentials: "include",
})
const data = await res.json()Services
No custom backend service classes are defined in this plugin source.
⚠️ Note: Most logic is implemented as configuration utilities (
src/config/*) and shared metadata helpers (src/shared/metadata/utils.ts) consumed by admin UI.
Workflows & Steps (Medusa v2)
No custom workflows or steps are defined in this plugin source.
The plugin calls Medusa core workflow updateProductVariantsWorkflow in POST /admin/product-variants/:id.
Subscribers / Event Hooks
No runtime subscribers/event handlers are defined in this plugin source (only template/readme scaffolding under src/subscribers).
Admin UI / Widgets
Universal Components
UniversalMetadataWidget(src/admin/components/universal-metadata-widget.tsx)- Detects entity type from widget zone; falls back to data-shape signals.
- Loads descriptors via
useMetadataConfig. - Renders
MetadataTableWidgetonly when descriptors exist.
MetadataTableWidget(src/admin/components/metadata-table.tsx)- Renders typed controls per descriptor.
- Performs descriptor-based validation.
- Uploads media/file fields through
POST /admin/uploadsand stores URL in metadata value. - Saves metadata by resolving entity endpoint (
resolveApiEndpoint) and callingPOST. - Invalidates/refetches React Query cache using resolved query keys.
Registered Metadata Widgets (wrappers)
Each file is a minimal wrapper around UniversalMetadataWidget with a fixed zone:
| Entity | Zone |
|---|---|
| products | product.details.after |
| orders | order.details.after |
| categories | product_category.details.after |
| collections | product_collection.details.after |
| customers | customer.details.after |
| regions | region.details.after |
| sales_channels | sales_channel.details.after |
| stores | store.details.after |
| promotions | promotion.details.after |
| campaigns | campaign.details.after |
| price_lists | price_list.details.after |
| shipping_profiles | shipping_profile.details.after |
| inventory_items | inventory_item.details.after |
| product_variants | product_variant.details.after |
| product_tags | product_tag.details.after |
| product_types | product_type.details.after |
Additional Widget
hide-default-metadata(src/admin/widgets/hide-default-metadata.tsx)- Zone:
product.details.side.before - Purpose: DOM-level hiding of default Medusa metadata panel to avoid UI duplication.
- Interaction: none (automatic MutationObserver behavior).
- Zone:
⚠️ Note: This widget hides DOM elements by structure/class heuristics and may need adjustment if Admin DOM structure changes in future Medusa releases.
Models & Entities
No custom database models/entities are defined in this plugin source.
The plugin operates on existing Medusa entities by reading/writing their metadata field via admin APIs.
Use Cases & Examples
Product enrichment without code changes
- Add new product metadata fields (brand, care instructions, support URLs) via plugin options.
- Use:
entities.products.descriptors+product.details.afterwidget.
Operational order annotations
- Add structured metadata to orders (source channel, routing note, internal tags).
- Use:
entities.orders.descriptors+ metadata table UI.
Per-entity metadata governance
- Make fields required and validated (
min/max/regex) to standardize admin data entry. - Use: descriptor
requiredandvalidation.
- Make fields required and validated (
Variant-level custom attributes
- Persist metadata on product variants and update through variant route workflow.
- Use:
POST /admin/product-variants/:id.
File/media metadata links
- Upload file/image/video/audio in metadata form and store uploaded URL in metadata.
- Use: file/media descriptor types in metadata table.
Known Issues & Fixes
Issue: Save disabled / wrong validation after descriptor type change
Problem:
Changing a field’s type in plugin options (for example select → text) while the admin metadata form was open could leave Save disabled incorrectly, or validate/coerce values under the new type while the form still held old-shaped values.
Root Cause: Dirty detection compared a coerced payload to raw stored metadata, and the form did not keep a frozen working descriptor set when config drifted mid-edit.
Solution:
- Dirty state compares two payloads built under the same descriptors (
buildInitialFormStatebaseline vs current values). - The widget keeps
appliedDescriptorsfor render, validation, dirty, and Save. Query results arelatestDescriptors. - Schema drift auto-adopts only when the form is clean. If dirty, a banner offers Apply config (discards unsaved edits and adopts the new schema).
- Invariant: After Save during a pending drift,
schemaDriftPendingstays true andappliedDescriptorsstay frozen. Do not clear the banner on Save or wire validate/payload tolatestDescriptorswhile pending — that reintroduces silent coercion bugs. Only Apply config (or a full entity remount) adopts the new schema.
Orphaned keys:
Descriptors removed from config are hidden from the form. The save payload includes only configured keys (existing buildMetadataPayload behavior). Whether removed keys remain in the database depends on Medusa entity update merge vs replace and is out of scope for this plugin.
Prevention:
- Always validate and build Save payloads with
appliedDescriptorsonly. - Never clear
schemaDriftPendingin the Save success path.
Issue: Save disabled while Reset is on (file keys and select labels)
Problem:
Changing an unrelated field (Reset enabled) left Save disabled because hasErrors stayed true. Stored file/media values that were S3 keys or relative paths failed new URL(), and select values stored as labels ("Classic") failed exact option-value match ("classic").
Root Cause:
File/image/video/audio validation required an absolute URL. Select/radio/multiselect/checkbox validation compared stored strings to option.value with exact case. Errors are computed for every visible field, so one invalid stored value blocked Save after any edit.
Solution:
- File/media refs use a scheme allowlist (
http:/https:without userinfo,blob:browser shape, raster-onlydata:image/png|jpeg|jpg|gif|webp). Rejectjavascript:,vbscript:,data:text/html,data:image/svg+xml, and..path segments. Do not fall through tonew URL(). Relative paths and object keys that match/^(\.\/|\/)?[\w][\w.\-/]*$/are accepted. normalizeFormValueandvalidateValueForDescriptorshareresolveOptionValue(exact value, then case-insensitive label, then case-insensitive value). First match wins.- Save remains
disabled={!isDirty || hasErrors || isSaving}.
Prevention:
- Do not trim inside the file-ref allowlist (whitespace rejection is intentional).
- Do not accept
data:image/svg+xmlor non-rasterdata:URIs. - Do not treat metadata file keys as browser-only hrefs; reject
..segments because shops maypath.jointhem.
Issue: Save disabled after changing a field from text to multiselect
Problem:
A key was saved as text (free string, placeholder copy, or comma-separated labels). After changing the descriptor to multiselect, checking allowed options still showed All selections must be from the allowed options and left Save disabled (Reset stayed enabled).
Root Cause:
normalizeFormValue wrapped the leftover string as a one-item array (["Enter specifications here"]). Checkboxes only toggle option.value, so they appended to that leftover instead of replacing it. Validation then rejected the invisible leftover.
Solution:
resolveSelectedOptionValuesexpands arrays, JSON array strings, and comma-separated text; maps labels tooption.value; drops unknown items; dedupes.- Form init, Save coerce, and checkbox toggles all use that helper, so a type change cannot keep a hidden invalid value in the selection list.
Prevention:
- Do not append checkbox values onto the raw stored array; canonicalize first.
- Do not keep unmapped leftover strings in multiselect/checkbox form state when options are configured.
Troubleshooting
Metadata widget doesn’t appear
- Cause: no descriptors configured for that entity.
- Fix: add
entities.<entityType>.descriptorsin plugin options; rebuild/restart app.
Product type / tag / settings pages: config exists but widget never shows
- Cause: the admin UI only renders the dynamic table after
/admin/metadata-config?entity=…returns descriptors. If the server cannot associate yourplugins[]entry with this package (for exampleresolveis a filesystem path,file:…, orrequire.resolve("medusa-dynamic-metadata")), options were previously ignored and the widget hid itself (built-in Metadata can still show 0 keys). - Fix: use
resolve: "medusa-dynamic-metadata"when possible, or any path whose segments include the foldermedusa-dynamic-metadata(path-basedresolvevalues are supported). Restart the server after changing config. - Verify:
curl -sS "http://localhost:9000/admin/metadata-config?entity=product_types" -H "Authorization: Bearer <token>"should return a non-emptymetadataDescriptorsarray whenproduct_typesis configured. - Admin bundle: after upgrading or developing the plugin locally, run
medusa plugin:build(ornpm run buildin the package) and restart so Admin loads the latest widgets (for exampleproduct-types-metadata-table).
“Unable to load metadata configuration”
- Cause: admin can’t reach
/admin/metadata-configor plugin not loaded. - Fix: verify plugin registration in
medusa-config.ts, rebuild plugin, restart Medusa server.
“No API endpoint configured for ”
- Cause: entity type lacks mapping in registry and no
api_endpointoverride configured. - Fix: set
api_endpoint(and optionallywidget_zone) for that entity in plugin options.
Save fails with backend error
- Cause: invalid payload, endpoint mismatch, or authorization issue.
- Fix: verify resolved endpoint, admin auth/session, and descriptor/value shape.
File upload fails in metadata field
- Cause:
/admin/uploadsrequest fails or no URL returned. - Fix: confirm uploads endpoint availability and permissions; inspect server response message shown in toast.
Default and custom metadata sections both visible
- Cause: hide widget not active for current zone/page or DOM structure mismatch.
- Fix: verify
hide-default-metadatawidget registration and adjust selector logic if Admin markup changed.
