npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

medusa-dynamic-metadata

v0.0.14

Published

A starter for Medusa plugins.

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 (entities map).
  • 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-metadata

Register 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.md contains example text referencing process.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).

2) GET /store/metadata-config

  • Auth: Storefront (store route namespace)
  • Query params:
    • entity (string, optional)
  • Response (when entity is provided and exposed):
    {
      "metadataDescriptors": [
        {
          "key": "brand",
          "type": "text",
          "label": "Brand"
        }
      ]
    }
  • Response (when entity is 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.

3) POST /admin/product-variants/:id

  • Auth: Admin (admin route namespace)
  • Path params:
    • id (string, required): product variant ID.
  • Body: passthrough payload with optional metadata object plus other variant update fields.
  • Response:
    {
      "product_variant": {
        "id": "variant_...",
        "metadata": {}
      }
    }
  • Behavior:
    • Executes updateProductVariantsWorkflow with selector by ID and update payload.
    • Returns first updated variant as product_variant.

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 MetadataTableWidget only 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/uploads and stores URL in metadata value.
    • Saves metadata by resolving entity endpoint (resolveApiEndpoint) and calling POST.
    • 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).

⚠️ 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

  1. Product enrichment without code changes

    • Add new product metadata fields (brand, care instructions, support URLs) via plugin options.
    • Use: entities.products.descriptors + product.details.after widget.
  2. Operational order annotations

    • Add structured metadata to orders (source channel, routing note, internal tags).
    • Use: entities.orders.descriptors + metadata table UI.
  3. Per-entity metadata governance

    • Make fields required and validated (min/max/regex) to standardize admin data entry.
    • Use: descriptor required and validation.
  4. Variant-level custom attributes

    • Persist metadata on product variants and update through variant route workflow.
    • Use: POST /admin/product-variants/:id.
  5. 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 selecttext) 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 (buildInitialFormState baseline vs current values).
  • The widget keeps appliedDescriptors for render, validation, dirty, and Save. Query results are latestDescriptors.
  • 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, schemaDriftPending stays true and appliedDescriptors stay frozen. Do not clear the banner on Save or wire validate/payload to latestDescriptors while 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 appliedDescriptors only.
  • Never clear schemaDriftPending in 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-only data:image/png|jpeg|jpg|gif|webp). Reject javascript:, vbscript:, data:text/html, data:image/svg+xml, and .. path segments. Do not fall through to new URL(). Relative paths and object keys that match /^(\.\/|\/)?[\w][\w.\-/]*$/ are accepted.
  • normalizeFormValue and validateValueForDescriptor share resolveOptionValue (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+xml or non-raster data: URIs.
  • Do not treat metadata file keys as browser-only hrefs; reject .. segments because shops may path.join them.

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:

  • resolveSelectedOptionValues expands arrays, JSON array strings, and comma-separated text; maps labels to option.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>.descriptors in 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 your plugins[] entry with this package (for example resolve is a filesystem path, file:…, or require.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 folder medusa-dynamic-metadata (path-based resolve values 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-empty metadataDescriptors array when product_types is configured.
  • Admin bundle: after upgrading or developing the plugin locally, run medusa plugin:build (or npm run build in the package) and restart so Admin loads the latest widgets (for example product-types-metadata-table).

“Unable to load metadata configuration”

  • Cause: admin can’t reach /admin/metadata-config or 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_endpoint override configured.
  • Fix: set api_endpoint (and optionally widget_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/uploads request 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-metadata widget registration and adjust selector logic if Admin markup changed.