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
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:
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:
| URL | Canonical |
|---|---|
| `/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 }.
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:
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:
| Check | Where | Cadence |
|---|---|---|
| Coverage errors | Search Console | Weekly |
| Core Web Vitals, field data | Search Console | Weekly |
| Structured data errors | Search Console → Enhancements | Weekly |
| Indexed page count | `site:` search or Coverage | Monthly |
| Crawl budget on facets | Server logs | Monthly |
| 404s with inbound links | Server logs | Monthly |
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.
Multi-Language Medusa Storefronts: Routing, Content and hreflang
Medusa handles currency and tax per region, not translation. How to layer language onto that — URL structure, product content, and the hreflang most stores get wrong.
Integrating Medusa with Sanity: Who Owns Which Field
The hard part of a commerce-plus-CMS setup is not the sync. It is deciding which system owns each field — and then never letting both own one.
Medusa Storefront Performance: Where the Milliseconds Actually Go
Over-fetching, waterfalls and unoptimised images account for most of a slow headless storefront. How to find them and what to do about each.



