@weareseeed/medusa-google-merchant-center-plugin
v0.0.2
Published
Google Merchant Center XML product feed for Medusa v2
Readme
Medusa Google Merchant Center Feed
Google Merchant Center XML product feed for Medusa v2. Serves a
token-protected feed URL with your catalog: region prices, sales channel
stock, admin settings, and a beforeItem hook to customize every item.
Compatibility
Medusa >= 2.11.0.
Setup
Register the plugin in
medusa-config.ts:plugins: [ { resolve: "@weareseeed/medusa-google-merchant-center-plugin", options: { logging: true, // optional: false (default) | true | "debug" // productFields: ["variants.weight"] (see "Extra product fields") // hooks: { beforeItem } (see "Customizing items") }, }, ],Run the migrations (they ship with the plugin):
npx medusa db:migrateIn the admin, open Google Merchant and set:
- [ ] Frontend URL: your storefront, e.g.
https://shop.example.com. Product links are{frontend URL}/products/{handle}. If your storefront prefixes a country code (like the Medusa Next.js starter), include it:https://shop.example.com/us. - [ ] Public backend URL: where this Medusa backend is reachable from the
internet, e.g.
https://api.example.com. Google fetches the feed from there, so don't uselocalhostor an internal address. Use current fills in the address the admin is using. - [ ] Pricing region: prices are Medusa's calculated price for this region, in its currency.
- [ ] Sales channel (optional): only its products are included, and stock counts only its stock locations. "All sales channels" includes every published product and counts all locations.
- [ ] Feed token: type one or click Generate (UUID). It is required in the URL.
Save. The page shows the feed URL, built from the public backend URL:
https://<public-backend-url>/google-merchant/feed?token=<token>. The endpoint is public; the token is what protects it.- [ ] Frontend URL: your storefront, e.g.
In Merchant Center: Products → Data sources → Add product source → Add products from a file. Enter the feed URL and choose a daily schedule. Then use Fetch now to test.
What goes in the feed
One <item> per variant of every published product (in the selected sales
channel, if any):
| Attribute | Value |
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| id | Variant SKU, or the variant id when it has no SKU |
| item_group_id | Product id, when the product has more than one variant |
| title | Product title plus the variant title, which holds its option values, e.g.Hoodie - Navy / M. A variant title that already contains the product name is used as-is. Single-variant products use the product title. Cut to 150 characters at a word boundary |
| description | Product description (falls back to the product title), cut to 5000 characters at a word boundary |
| link | {frontend URL}/products/{handle} |
| image_link | Variant thumbnail, else the product thumbnail, else the first product image (by gallery order) |
| additional_image_link | Up to 10 other product images, in gallery order |
| price | Calculated price in the pricing region, e.g.49.99 USD (price lists included) |
| availability | in_stock if inventory isn't managed or stock > 0; otherwise backorder if allowed, else out_of_stock |
| condition | new |
| color / size / material | From variant options with those names (variant.material wins) |
A variant is left out when it has no image or no price in the region, or
when its final item (after beforeItem) has an id longer than 50
characters or a price of zero or less. Google rejects those items. With
logging: "debug", the reason is logged. Other limits are left for Merchant
Center to report. The feed ends with a comment
<!-- items: N, skipped: M -->.
Tax: the price is sent exactly as Medusa calculates it. Whether it includes tax follows the region's price settings. Google expects tax-exclusive prices for the US and Canada (configure tax in Merchant Center) and tax-inclusive prices for VAT countries (EU, UK, AU…).
Customizing items
beforeItem runs for every item. It receives a copy of the default item and
returns the item to include, modified or not. Returning undefined, null or
"skip" leaves it out. Items use Google's
attribute names:
arrays become repeated elements, objects become nested elements (e.g.
shipping), and true/false are written as yes/no.
options: {
hooks: {
beforeItem: (item, ctx) => {
if (ctx.product.metadata?.hide_from_google) {
return "skip"
}
return {
...item,
brand: (ctx.product.metadata?.brand as string) ?? "Acme",
gtin: ctx.variant.ean ?? undefined,
identifier_exists: Boolean(ctx.variant.ean),
product_type: ctx.product.type?.value,
shipping: { country: "US", service: "Standard", price: "5.99 USD" },
}
},
},
}ctx contains product (with metadata, type, collection, tags,
images, variants), variant (with sku, ean, upc, barcode,
metadata, options, calculated_price, availability), settings (without
the feed token), region, salesChannel, container and logger. The hook may be async.
The returned item is final; text you return is not truncated. It is only
left out (and logged) if it lacks id, title, link, image_link or
price, if its id is longer than 50 characters, if its price is zero or
less, or if the hook throws.
Extra product fields
ctx.product and ctx.variant only contain the fields the feed needs. To read
more in beforeItem, list them in productFields. They are added to the
default fields (the defaults can't be removed) and fetched in the same batched
query, so there's no per-item lookup. Use
Query field
syntax; fields from linked modules (e.g. brand.name) need the link to exist
in your app.
options: {
productFields: ["variants.weight", "categories.name", "brand.name"],
hooks: {
beforeItem: (item, { product, variant }) => ({
...item,
brand: (product.brand as { name: string } | null)?.name,
shipping_weight: variant.weight ? `${variant.weight} g` : undefined,
product_type: (product.categories as { name: string }[] | undefined)
?.map((category) => category.name)
.join(" > "),
}),
},
}Extra fields are typed as unknown, so cast them when you read them. A field
that doesn't exist makes the query fail, which leaves the feed incomplete
(see below) and logs the error. With logging: "debug" the added fields are
logged on every feed request.
Endpoint behaviour
GET /google-merchant/feed?token=… is public (outside /store and /admin,
so Google can fetch it without headers):
- 401 if the token is missing or wrong.
- 503 if the feed isn't configured (no frontend URL or region, or the selected region/channel was deleted), with the reason in the body.
- 200 otherwise: the feed is generated live from the database and streamed.
If an error happens while streaming, the document is left incomplete on purpose. Merchant Center then reports a fetch error and keeps your products, instead of deleting everything missing from a truncated file.
The token is part of the URL, so it can appear in proxy and access logs. If
the backend sits behind nginx, disable response buffering for
/google-merchant/feed. The endpoint sends X-Accel-Buffering: no.
