medusa-plugin-bluestone
Imports a Bluestone PIM catalog into Medusa, so a Medusa shop gets its categories, products, variants, translations and localized slugs from the PIM.
Prices and stock can be imported too, where you ask for them. An import runs from a widget in the admin, an admin route, a workflow or the server's start, and it records every run so you can follow it while it happens.
When to use
Install it in a Medusa backend whose product data comes from Bluestone PIM. It
reads the same environment variables as
@evolve-framework/bluestone-commercetools, the sync for commercetools, so a
deployment that runs both configures Bluestone once.
Install @evolve-framework/medusa-plugin-slug as well to get a slug per
locale; without it, the import writes no slugs. Translations are written with
Medusa's Translation Module, which needs featureFlags: { translation: true }.
Get started
Install the package with the peer dependencies it needs. It comes from the Evolve registry, which needs a one-time sign-in.
pnpm add @evolve-framework/medusa-plugin-bluestone @medusajs/cli @medusajs/framework @medusajs/medusaRequirements4
node>=20@medusajs/cli2.21.2@medusajs/framework2.21.2@medusajs/medusa2.21.2
For example:
// medusa-config.ts// After adding the plugin, run `npx medusa db:migrate` to create its table.import { defineConfig } from "@medusajs/framework/utils";
module.exports = defineConfig({ // projectConfig, modules and other plugins as before plugins: [ { resolve: "@evolve-framework/medusa-plugin-slug", options: {} }, { resolve: "@evolve-framework/medusa-plugin-bluestone", options: { pricing: { strategy: "generated", band: [5, 500] }, stock: { strategy: "default", quantity: 100 }, }, }, ],});What it imports
| Bluestone | Medusa |
|---|---|
| Category subtree under the webshop root | product_category, matched on metadata.bluestone_id |
GROUP and SINGLE products |
product, matched on external_id |
VARIANT products |
product_variant on their parent |
| Attributes named as option axes | product options and variant option values |
| Product relations of a mapped type | metadata.relations on the product or variant |
| Non-base publication contexts | translations of products, categories, option titles and option values, only where they differ from base |
| Every context | one slug per locale via medusa-plugin-slug |
The root category itself is not imported: it is Bluestone's container for the tree, not a category a shopper should see.
A PIM holds no prices and no stock. What the plugin does about that is a configured decision, never a default, as described under Prices and stock.
Configuration
Every option can be set in medusa-config.ts or in the environment. A plugin
option wins over its environment variable.
| Option | Environment | Meaning |
|---|---|---|
apiKey |
BLUESTONE_API_KEY |
Bluestone API key. Required |
apiUrl |
BLUESTONE_API_URL |
Bluestone API URL. Required |
rootCategoryId |
BLUESTONE_CATEGORY_WEBSHOP_ID |
The category whose subtree is the webshop. Required |
contextMapping |
BLUESTONE_CONTEXT_MAPPING |
Context to locales, as JSON in the environment. Required |
baseContext |
BLUESTONE_BASE_CONTEXT |
The context written onto the record itself. Default en |
optionAttributes |
BLUESTONE_OPTION_ATTRIBUTES |
Attributes that become product options, most significant first; comma-separated or JSON |
optionTitles |
BLUESTONE_OPTION_TITLES |
Attribute number to the option title shoppers see, as JSON in the environment |
relationTypes |
BLUESTONE_RELATION_TYPES |
Relation number or name to accessories, spareParts, complementaryProducts or bundleProducts, as JSON in the environment. Default accessory, spare-part, complementary and bundle |
chunkSize |
BLUESTONE_CHUNK_SIZE |
Products per write, at most 200. Default 50 |
chunkDelayMs |
BLUESTONE_CHUNK_DELAY_MS |
Pause between writes, in milliseconds. Default 0 |
pricing |
BLUESTONE_PRICING_STRATEGY |
attribute, generated or none. Required, no default |
pricing.attribute |
BLUESTONE_PRICE_ATTRIBUTE |
The Bluestone attribute that holds the price, for attribute |
pricing.onMissing |
BLUESTONE_PRICE_ON_MISSING |
reject or unpriced, for attribute. Default reject |
pricing.band |
BLUESTONE_PRICE_BAND |
Price bounds as a JSON pair, for generated. Default [5, 500] |
stock |
BLUESTONE_STOCK_STRATEGY |
default or none. Required, no default |
stock.quantity |
BLUESTONE_STOCK_QUANTITY |
The stock level written where a variant has none, for default. Default 100 |
BLUESTONE_IMPORT_ON_BOOTSTRAP |
true starts an import when the server starts. Off by default |
Nothing is validated at boot. A Medusa that cannot reach Bluestone still starts and serves; the configuration is checked when an import is asked for, which is the first moment it matters and the only moment there is somebody to tell.
Running an import
Four ways in, one workflow behind them:
- the Bluestone import widget on the products list page in the admin;
POST /admin/bluestone/imports, which returns a run id immediately and does not wait — a full catalog takes minutes, far past any HTTP timeout;BLUESTONE_IMPORT_ON_BOOTSTRAP, described below;importBluestoneCatalogWorkflow, for a scheduled job.
All four go through the same guard, so a button and a starting server can never disagree about when an import may begin.
Progress and results:
GET /admin/bluestone/imports list runs
GET /admin/bluestone/imports/latest the current or most recent run
GET /admin/bluestone/imports/:id one run
POST /admin/bluestone/imports/:id/cancel
Counts are written per chunk rather than at the end, so a run is observable
while it happens. Canceling is cooperative: the run stops between pages and
finishes as cancelled, which is not the same as failed.
Only one import runs at a time. A second request is refused rather than queued.
Set BLUESTONE_IMPORT_ON_BOOTSTRAP=true and the server starts one when it comes
up. Off by default, because filling a catalog is not a decision a server
should make on its own — a restart during business hours would rewrite every
product in the shop. It exists for the case it is named after: an environment
coming up from nothing, where the alternative is somebody remembering to press a
button before anyone can browse.
The trigger is a scheduled job that runs once, in the first minute after the
server starts, so it only fires where jobs run: a worker or shared instance,
not a server. It never awaits the import, so a server that cannot reach the
PIM still serves, and a run already in progress is left alone with a line in
the log rather than being queued behind itself.
After an import
The plugin writes to Medusa and nothing else. Everything downstream follows the catalog events Medusa emits, and those are forwarded fire-and-forget — a consumer that is slow, restarting, or not yet listening simply misses them. So the tail of a full import is three steps, and they are not optional bookkeeping:
- back-fill catalog pages in the CMS;
- rebuild any navigation built from the category tree;
- reindex search.
In the Evolve reference these are seed:catalog-pages, seed:navigation and
reindex in the discover service. Two things worth knowing before trusting a
run: a category tree is written in the import's first seconds, so a consumer
still booting misses nearly all of it — one measured run back-filled 143 of 147
pages that events should have created. And a storefront that caches its listings
keeps serving the old catalog afterwards, with every server-side check
reporting success.
Variant options
Bluestone has no notion of an option axis. It has variants, attributes on them,
and a definingAttribute flag meant for exactly this — a flag that is unset in
every tenant seen so far. So the axes are configured by attribute number (such
as paint-finish, not its display name), the flag is the fallback, and
optionAttributes is the setting that matters most.
Two things follow from that, both of which cost real imports to learn:
Naming too few axes is expensive. Every variant that cannot be told apart collapses onto the same option tuple, and all but the first are rejected as duplicates — Medusa refuses duplicate option combinations outright. Naming an axis no variant carries costs nothing: it is dropped rather than added empty.
The run tells you what it needed. Where two variants collide, the run
records axis_hints: the attributes that differ between exactly those two and
are not configured. That turns "412 rejected" into a list of attribute numbers.
It reports rather than acts, deliberately — one tenant's hints included
sizeSequence, a numeric sort key that moves with the size and would have put
ordering numbers in a storefront dropdown.
An axis is titled with the attribute's display name in the base context, and
falls back to its number when Bluestone gives it no name. optionTitles names
an axis instead, for the case where the display name is still written for the
people who maintain the PIM: one tenant has an axis called option whose
values are "High Handlebar" and "Low Handlebar". Its default titles that one
Handlebar and both size and sizeText Size.
Option titles and values are translated like product content: per locale,
from the matching context, and only where they differ from the base. A value is
read from the same variant in the other context. A title set in
optionTitles is kept in every locale. The specification attributes in
metadata.attributes are not translated, because Medusa cannot translate
metadata, so their labels stay in the base context.
Each option records the attribute it stands for in metadata.bluestone_attribute.
A rerun matches options on it, so an axis whose display name changed is renamed
in place rather than reported as changed. Options from an import before this
existed, titled with the attribute number or a default title, are recognized,
renamed and tagged on the next run.
A product whose variants nothing distinguishes still imports. One variant gets Medusa's own default option; several get an axis keyed on the variant number, which is ugly in a storefront and honest — the source offers nothing better, and dropping real variants to keep the catalog tidy is the worse trade.
Product relations
Bluestone relates products as accessories, spare parts, complementary products
and bundle extras. The commercetools sites keep these in product reference
attributes. Medusa has no field for them, so the import writes them to
metadata.relations, as Medusa ids a storefront can load with the Store API:
{
"relations": [
{ "kind": "accessories", "product_id": "prod_01…" },
{ "kind": "spareParts", "product_id": "prod_01…", "variant_id": "variant_01…" },
{ "kind": "complementaryProducts", "product_id": "prod_01…" },
{ "kind": "bundleProducts", "product_id": "prod_01…" }
]
}
kindis the related-products field of the Evolve product contract the relation fills.relationTypessays which Bluestone relation type fills which. A relation of another type is not imported.product_idis the related product.variant_idis there when Bluestone relates a specific variant rather than a product.- Entries are in Bluestone's order, without repeats.
- Relations of a
GROUPorSINGLEproduct go on the Medusa product. Relations aVARIANThas of its own go on that variant. A reader takes a variant's own relations of a kind over its product's. - A relation to a product this webshop does not sell, outside the category tree or not imported, is left out and counted in the log. So is a relation to the product itself.
- Bluestone also lists a relation on the related product, marked
reverse. That copy is not imported, for a one-way and a two-way relation alike: a roller is not an accessory of the paint that lists it as one.
The relations are written in a step after every product, because a relation can point at a product a later chunk creates. The step finds related products among everything any import wrote, and writes only relations that changed: each write is a product event, and a rerun should not reindex the catalog. A relation that left Bluestone leaves Medusa on the next run.
Relations are one array rather than an object per kind because Medusa merges objects in metadata into what is stored, and replaces arrays. Only an array lets a removed relation go.
The related products are read like any product, from the Store API with the storefront's publishable key. No route or admin credentials are involved.
Prices and stock
Neither has a default, and that is the point. A PIM holds no prices, so every strategy here is a decision about commercial data, and the one that invents it must be asked for by name. A default — any default — is how generated prices end up in a production catalog with nobody having chosen them. An import refuses to start until both are set.
| Pricing | Behavior |
|---|---|
attribute |
read from a named Bluestone attribute; onMissing decides reject or unpriced — never a generated fallback |
generated |
derived from the SKU, stable across runs, within band |
none |
no prices are written |
| Stock | Behavior |
|---|---|
default |
quantity at the stock location where none exists |
none |
no inventory levels are written |
Generated values are marked on the record — metadata.price_source,
metadata.stock_source — and counted on the run, so a catalog can always
answer "which of these numbers did we make up". The marker is written positively
in both directions (generated or attribute), so a later run supplying a real
price overwrites it rather than relying on a key being removed.

