Medusa gives you a commerce API and stops. What you build against it is the entire customer experience, and the decisions that matter are made in the first week: what renders on the server, what caches, and where cart state lives.
Get those three right and the rest is design work.
The rendering decision
Every page falls into one of three buckets, and putting a page in the wrong one is the most common performance mistake in a headless build.
| Page | Strategy | Why |
|---|---|---|
| Home, category, product | Static + revalidate | Same for everyone; must be fast |
| Search results | Dynamic, cached briefly | Query-dependent |
| Cart, checkout, account | Dynamic, never cached | Per-user |
import { notFound } from "next/navigation"
import { sdk } from "@/lib/medusa"
export const revalidate = 3600
export async function generateStaticParams() {
const { products } = await sdk.store.product.list({ fields: "handle", limit: 1000 })
return products.map((p) => ({ handle: p.handle! }))
}
export async function generateMetadata({ params }: { params: Promise<{ handle: string }> }) {
const { handle } = await params
const product = await getProduct(handle)
if (!product) return {}
return {
title: product.title,
description: product.description?.slice(0, 155),
alternates: { canonical: `/products/${handle}` },
openGraph: { images: product.thumbnail ? [product.thumbnail] : [] },
}
}
export default async function ProductPage({ params }: { params: Promise<{ handle: string }> }) {
const { handle } = await params
const product = await getProduct(handle)
if (!product) return notFound()
return <ProductTemplate product={product} />
}generateStaticParams plus revalidate gives you static HTML that refreshes on a schedule. For a catalog of any size this is the difference between a 200ms page and a 900ms one.
The SDK client, server-side
import Medusa from "@medusajs/js-sdk"
export const sdk = new Medusa({
baseUrl: process.env.MEDUSA_BACKEND_URL!,
publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!,
})Instantiate once and import it. Detail in the Medusa JS SDK guide.
Fetch in server components rather than the browser wherever you can: the request travels backend-to-backend rather than over the customer's connection, and the payload never reaches the client bundle.
Cache tags
Blanket revalidation is wasteful; per-page revalidation is fiddly. Tags are the middle ground:
import { unstable_cache } from "next/cache"
export const getProduct = (handle: string) =>
unstable_cache(
async () => {
const { products } = await sdk.store.product.list({
handle,
fields: "*variants.calculated_price,+variants.inventory_quantity",
})
return products[0] ?? null
},
["product", handle],
{ tags: [`product-${handle}`, "products"], revalidate: 3600 },
)()Then a webhook from Medusa can invalidate precisely:
import { revalidateTag } from "next/cache"
export async function POST(request: Request) {
const secret = request.headers.get("x-revalidate-secret")
if (secret !== process.env.REVALIDATE_SECRET) {
return new Response("Unauthorized", { status: 401 })
}
const { handle } = await request.json()
revalidateTag(handle ? `product-${handle}` : "products")
return Response.json({ revalidated: true })
}Fire it from a subscriber on product.updated. Editors change a price and the page updates in seconds, without dropping the whole cache.
Cart state
The cart id goes in an httpOnly cookie. Not localStorage — that is readable by any script on the page and does not exist during server rendering, which forces a client-side fetch and a layout shift on every page load.
"use server"
import { cookies } from "next/headers"
import { revalidateTag } from "next/cache"
import { sdk } from "@/lib/medusa"
export async function getOrCreateCart(regionId: string) {
const store = await cookies()
const existing = store.get("_medusa_cart_id")?.value
if (existing) {
const { cart } = await sdk.store.cart.retrieve(existing)
if (cart && !cart.completed_at) return cart
}
const { cart } = await sdk.store.cart.create({ region_id: regionId })
store.set("_medusa_cart_id", cart.id, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 30,
})
return cart
}
export async function addLineItem(variantId: string, quantity: number) {
const cart = await getOrCreateCart(process.env.NEXT_PUBLIC_DEFAULT_REGION!)
await sdk.store.cart.createLineItem(cart.id, { variant_id: variantId, quantity })
revalidateTag("cart")
}Server actions for mutations, revalidateTag to refresh the cart UI. No client-side cart library, no state synchronisation bugs. Cart implementation in depth.
Project structure
src/
├── app/
│ ├── (main)/ # storefront pages
│ │ ├── products/[handle]/
│ │ ├── collections/[handle]/
│ │ └── cart/
│ ├── (checkout)/ # its own layout, no nav
│ └── api/revalidate/
├── lib/
│ ├── medusa.ts # SDK client
│ └── data/ # server-only fetchers, one file per domain
└── modules/ # feature componentsKeep every Medusa call inside lib/data/. When the API changes — and it will — you edit one directory rather than grepping for sdk. across the codebase.
Images
Product images come from Medusa's file storage. Add the host to next.config.js and let Next optimise them:
images: {
remotePatterns: [{ protocol: "https", hostname: "cdn.yourdomain.com" }],
}Set priority on the product hero and the first row of a category grid. Everything below the fold lazy-loads by default. This is usually the largest single LCP win available. More in storefront performance.
Deploy separately
Backend and storefront are different applications with different scaling profiles. Storefront on Vercel or another CDN-first host; backend on Railway or AWS. Add the storefront's origins — including preview URLs — to the backend's STORE_CORS.
Error and loading states
Commerce pages fail in specific ways, and generic error boundaries make them worse. Give each route segment its own:
"use client"
export default function ProductError({ reset }: { error: Error; reset: () => void }) {
return (
<div role="alert">
<h1>We could not load this product</h1>
<p>It may have been removed, or our systems may be having a moment.</p>
<button onClick={reset}>Try again</button>
<a href="/collections">Browse everything</a>
</div>
)
}Always offer a route out. A dead end on a product page loses the session; a link to the category keeps it.
For loading, use loading.tsx with a skeleton that matches the real layout's dimensions — a skeleton of the wrong height causes the layout shift you were trying to avoid.
Accessibility that affects conversion
Three things worth building in from the start, because retrofitting them is far more expensive:
Announce cart changes. An aria-live="polite" region that says "Added to cart, 3 items" — otherwise screen reader users get no feedback that the button worked.
Label controls properly. aria-label="Increase quantity" on a + button. An unlabelled icon button is invisible to assistive technology.
Keep focus after a mutation. Adding to cart should not move focus to the top of the page. Return it to the control the user pressed, or move it deliberately to the cart drawer.
Beyond compliance, these are the same behaviours that make a keyboard-driven checkout feel fast for everyone.
We build these for a living, and the first-week decisions are where we earn our keep. Talk to us.
Frequently asked questions
Should I use the official Medusa Next.js starter?
For a first build, yes — it implements browsing, cart, checkout and account against the current API, which is faster than deriving it from the reference. Expect to replace most of the presentation layer, and keep its data-fetching structure.
Should Medusa data be fetched on the server or the client?
On the server wherever possible. Server components fetch backend-to-backend, keep payloads out of the client bundle and render HTML with data already in place. Reserve client fetching for genuinely interactive things like search-as-you-type.
Where should the cart id be stored?
In an httpOnly cookie. It is available during server rendering, unreadable by scripts on the page, and avoids the client-side fetch and layout shift that localStorage forces on every page load.
How do I revalidate storefront pages when a product changes?
Tag your cached fetches, expose a secret-protected route handler that calls `revalidateTag`, and call it from a Medusa subscriber on `product.updated`. That invalidates exactly the affected pages rather than the entire cache.
Can I use a framework other than Next.js?
Yes — Medusa's Store API is framework-agnostic and teams ship Nuxt, SvelteKit, Astro and Remix storefronts. Next.js has the most complete reference implementation, which mainly matters for how much you have to work out yourself.
How do I handle multiple regions in the storefront?
Prefix routes with a country code and resolve the Medusa region from it, so `/us/products/x` and `/de/products/x` render with the right currency, tax treatment and availability. See [multi-region setup](/blog/medusa-multi-region).
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.
Caching Medusa: What to Cache, Where, and What Never To
Four cache layers, one rule about carts, and the invalidation strategy that stops a price change taking twelve hours to appear.
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.



