3 MIN READ

Building a Medusa Storefront in Next.js: Architecture and Patterns

Server components, caching, cart state and the rendering decisions that determine whether your storefront is fast. The architecture we use on every Medusa build.

BY ANANYA IYERUPDATED
Illustration for “Building a Medusa Storefront in Next.js: Architecture and Patterns” — Storefront

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.

PageStrategyWhy
Home, category, productStatic + revalidateSame for everyone; must be fast
Search resultsDynamic, cached brieflyQuery-dependent
Cart, checkout, accountDynamic, never cachedPer-user
app/products/[handle]/page.tsxtsx
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

lib/medusa.tsts
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:

lib/data/products.tsts
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:

app/api/revalidate/route.tsts
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.

lib/data/cart.tsts
"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

text
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 components

Keep 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:

js
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:

app/products/[handle]/error.tsxtsx
"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).

[ Keep reading ]