3 MIN READ

Medusa Storefront SEO: Structured Data, Canonicals and Facets

Headless means you own every SEO decision, including the ones a hosted platform used to make. Metadata, Product schema, canonical rules and the faceted-navigation trap.

BY SHUBHAM VERMAUPDATED
Illustration for “Medusa Storefront SEO: Structured Data, Canonicals and Facets” — SEO

On Shopify, a great deal of technical SEO is decided for you. Going headless hands all of it back — which is an opportunity if you know what to do and a liability if you assume it is handled.

This is the checklist we implement on every Medusa storefront.

Metadata

app/products/[handle]/page.tsxtsx
export async function generateMetadata({ params }: { params: Promise<{ handle: string }> }) {
  const { handle } = await params
  const product = await getProduct(handle)
  if (!product) return { title: "Not found", robots: { index: false } }

  const description =
    product.subtitle ??
    product.description?.replace(/<[^>]+>/g, "").slice(0, 155) ??
    `Buy ${product.title}.`

  return {
    title: product.title,
    description,
    alternates: { canonical: `/products/${handle}` },
    openGraph: {
      type: "website",
      title: product.title,
      description,
      images: product.thumbnail ? [{ url: product.thumbnail, width: 1200, height: 630 }] : [],
    },
  }
}

Two things people get wrong. Strip HTML from descriptions before using them as meta descriptions — Medusa stores rich text and raw tags in a meta tag look broken in results. And never use the same description across variants of a page; duplicate descriptions across a catalog suppress the whole set.

Product structured data

The markup that produces price, availability and review stars in search results:

components/ProductJsonLd.tsxtsx
export function ProductJsonLd({ product, url }: { product: StoreProduct; url: string }) {
  const variants = product.variants ?? []
  const prices = variants
    .map((v) => v.calculated_price?.calculated_amount)
    .filter((n): n is number => typeof n === "number")

  const inStock = variants.some((v) => (v.inventory_quantity ?? 0) > 0 || !v.manage_inventory)

  const data = {
    "@context": "https://schema.org",
    "@type": "Product",
    name: product.title,
    description: product.description?.replace(/<[^>]+>/g, ""),
    image: [product.thumbnail, ...(product.images ?? []).map((i) => i.url)].filter(Boolean),
    sku: variants[0]?.sku ?? undefined,
    brand: product.collection ? { "@type": "Brand", name: product.collection.title } : undefined,
    offers: prices.length > 1
      ? {
          "@type": "AggregateOffer",
          priceCurrency: variants[0]?.calculated_price?.currency_code?.toUpperCase(),
          lowPrice: (Math.min(...prices) / 100).toFixed(2),
          highPrice: (Math.max(...prices) / 100).toFixed(2),
          offerCount: variants.length,
          availability: inStock
            ? "https://schema.org/InStock"
            : "https://schema.org/OutOfStock",
          url,
        }
      : {
          "@type": "Offer",
          price: ((prices[0] ?? 0) / 100).toFixed(2),
          priceCurrency: variants[0]?.calculated_price?.currency_code?.toUpperCase(),
          availability: inStock
            ? "https://schema.org/InStock"
            : "https://schema.org/OutOfStock",
          url,
        },
  }

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  )
}

Medusa stores amounts in the currency's minor unit, so divide by 100 before emitting. Getting that wrong publishes a £4,500 T-shirt, and Google will believe you.

Add BreadcrumbList on product and category pages, and Organization once in the root layout.

Canonicals

Rules that cover almost every case:

URLCanonical
`/products/x`Itself
`/collections/y/products/x``/products/x` — do not nest product URLs
`/collections/y?page=2`Itself
`/collections/y?sort=price``/collections/y`
`/collections/y?color=blue`Depends — see below

Sort orders are the same products in a different sequence, so they canonicalise to the base. Pagination pages are distinct sets and should self-canonicalise, not point at page one.

The faceted navigation trap

Five filters with four values each generates over a thousand URLs per category, nearly all of them near-duplicates. Left alone, Google spends its crawl budget on ?color=blue&size=m&sort=price-asc instead of your products.

Split facets into two groups:

Indexable — combinations with real search demand, given clean URLs: /collections/shoes/black, /collections/shoes/waterproof. One or at most two facets deep, self-canonical, unique titles and an intro paragraph.

Not indexable — everything else, as query parameters, canonicalised to the base category, with robots: { index: false, follow: true }.

tsx
export async function generateMetadata({ searchParams }) {
  const sp = await searchParams
  const isFiltered = Object.keys(sp).some((k) => k !== "page")

  return {
    alternates: { canonical: `/collections/${handle}` },
    robots: isFiltered ? { index: false, follow: true } : undefined,
  }
}

Decide this before launch. Retrofitting it means waiting months for a bloated index to shrink.

Sitemaps

Generate from the catalog, not by hand:

app/sitemap.tsts
import type { MetadataRoute } from "next"

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const { products } = await sdk.store.product.list({
    limit: 5000,
    fields: "handle,updated_at",
  })

  return products.map((p) => ({
    url: `${process.env.NEXT_PUBLIC_SITE_URL}/products/${p.handle}`,
    lastModified: p.updated_at ? new Date(p.updated_at) : undefined,
    changeFrequency: "weekly",
    priority: 0.8,
  }))
}

Real lastModified values from updated_at — not today's date on every URL, which teaches crawlers to ignore the field. Split into a sitemap index above 50,000 URLs.

Out-of-stock products

Do not 404 or delete them. They hold links and rankings. Keep the page, mark availability as OutOfStock in the structured data, and offer alternatives or a restock notification. Only 410 a product that is genuinely never coming back — and 301 it to the closest category if it has links.

Core Web Vitals

Product images are almost always the LCP element. Set priority on the hero and the first row of a grid, serve correctly sized images, and keep the catalog on static rendering with revalidation. Storefront performance goes deeper.

Migration redirects

If you are arriving from another platform, the redirect map matters more than everything above combined. Migration guide.

Internal linking

The most underrated technical SEO work on a store, and the cheapest.

Breadcrumbs on every product page, marked up with BreadcrumbList. They give crawlers the category hierarchy and give customers a way up.

Related products with descriptive anchor text. "You might also like" links with the product name as the anchor, not "view".

Category cross-links. A "shop by material" block linking your indexable facet pages does more for those pages than any amount of on-page optimisation, because it is the only internal link they will otherwise get.

Link from content to commerce. Buying guides that link to the products they discuss pass authority to pages that convert.

The pattern to avoid is a store where every internal link is either navigation or pagination. Those pages get crawled and ranked according to a hierarchy nobody designed.

Monitoring after launch

Set up in the first week, because problems are cheap to fix early and expensive to discover in month three:

CheckWhereCadence
Coverage errorsSearch ConsoleWeekly
Core Web Vitals, field dataSearch ConsoleWeekly
Structured data errorsSearch Console → EnhancementsWeekly
Indexed page count`site:` search or CoverageMonthly
Crawl budget on facetsServer logsMonthly
404s with inbound linksServer logsMonthly

Server logs are the one people skip and the one that catches faceted-navigation bloat — you will see crawlers spending thousands of requests on ?sort= variations long before it shows up anywhere else.


We treat SEO as an engineering deliverable rather than a marketing afterthought. Ask about an audit.

Frequently asked questions

What structured data should a product page have?

`Product` with an `offers` block giving price, currency and availability — `AggregateOffer` when variants span a price range — plus `BreadcrumbList` for the category path and `Organization` once site-wide. That combination is what produces price and availability in search results.

How should faceted navigation be handled for SEO?

Choose a small set of filter combinations with real search demand, give them clean crawlable URLs with unique metadata, and make everything else a query parameter that is `noindex, follow` and canonicalised to the base category.

Should paginated category pages be canonicalised to page one?

No. Each page holds a distinct set of products, so it should canonicalise to itself. Pointing them all at page one hides the products on later pages from the index.

What should happen to out-of-stock product pages?

Keep them live with `OutOfStock` in the structured data and a restock notification or alternatives. They retain links and rankings. Only remove a page when the product is permanently gone, and redirect it to the nearest category if it has inbound links.

How do I generate a sitemap for a Medusa storefront?

Fetch handles and `updated_at` from the Store API and emit them from `app/sitemap.ts` with real `lastModified` values. Above 50,000 URLs, split into a sitemap index with separate files for products, categories and content.

Does going headless hurt SEO?

Only if you neglect it. A headless storefront gives you full control of rendering, metadata and performance, which usually beats a hosted theme — but nothing is handled for you, so every decision a platform used to make is now yours to make explicitly.

[ Keep reading ]