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

@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

  1. 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")
        },
      },
    ],
  2. Run the migrations (they ship with the plugin):

    npx medusa db:migrate
  3. In 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 use localhost or 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.

  4. 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.