Guides · February 8, 2023
Build a Next.js Storefront on Medusa v2, Step by Step
Here is how to wire a Next.js App Router storefront to Medusa v2's Store API end to end: modules and regions, the JS Client vs. raw fetch, product listing and PDP, cart and checkout, and where to cache what.
By Polo Themes
Building a Next.js storefront on Medusa v2 comes down to four moving pieces: a Medusa backend exposing modules (product, cart, order, region) through the Store API, a publishable API key scoped to a sales channel, a Next.js App Router front end that fetches through the Medusa JS Client (or plain fetch) inside React Server Components, and a caching strategy that treats product data as revalidate-on-demand rather than always-fresh. Get those four right and everything else — cart state, checkout, regions and currencies — falls into place. This tutorial walks through each one in order, with the specific gotchas that trip people up moving from Medusa v1 or from a generic REST-backed storefront.
This is a from-scratch build guide, not a marketing page. Medusa v2 is a real architectural departure from v1 — modules replaced the monolithic services layer, the admin and store APIs were rewritten, and the workflow engine changed how you orchestrate multi-step operations like "place order." If you built a Medusa storefront on v1, expect to relearn several conventions rather than port code directly.
What You're Building and Why This Stack
Medusa v2 is a headless commerce engine: it owns products, pricing, inventory, carts, orders, and fulfillment, and exposes all of it over a documented REST API split into an Admin API (authenticated, for back-office operations) and a Store API (publishable-key-scoped, for customer-facing storefronts). Next.js's App Router is a strong match for this because Server Components can fetch store data directly at request or build time — no client-side loading spinner for a product page — while Client Components handle genuinely interactive state like an add-to-cart button or a quantity stepper.
The alternative architectures worth naming before you commit: Shopify's Storefront API is a mature, batteries-included option if you don't need Medusa's self-hosted flexibility or its module system; Vendure is a comparable open-source, TypeScript-native alternative to Medusa with a similar headless philosophy. If you already know you want to own your backend, keep pricing logic in your own database, and extend commerce behavior with custom modules rather than fight a SaaS platform's extension model, Medusa v2 is a defensible choice, and this guide assumes you've made that call.
Prerequisites and Project Shape
You need a running Medusa v2 backend before writing a line of storefront code. If you don't have one, scaffold it with the official create command, which sets up the server, an admin dashboard, and a Postgres connection: npx create-medusa-app@latest. Follow its prompts to create an admin user and seed demo data — the seed script gives you a region, a sales channel, and a handful of products, which is enough to build a real storefront against instead of guessing at shapes.
Two backend concepts you must have correct before the storefront will return anything: a publishable API key and a sales channel. Every Store API request needs a publishable key in the x-publishable-api-key header, and that key must be linked to the sales channel your products belong to — a key with no linked sales channel, or a key linked to the wrong one, returns an empty product list with no error, which is the single most common "why is nothing showing up" bug in a fresh Medusa storefront. Check this in the admin dashboard under Settings → API Key Management before you write any fetch code.
On the Next.js side, scaffold a standard App Router project: npx create-next-app@latest my-storefront, TypeScript and the App Router enabled. You do not need MDX, a CMS, or a component registry to start — just the framework, a way to call the Store API, and a place to put environment variables.
Environment variables
Set at minimum: NEXT_PUBLIC_MEDUSA_BACKEND_URL (your backend's origin, e.g. http://localhost:9000 in dev) and NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY (the key from the admin dashboard). The NEXT_PUBLIC_ prefix matters — Next.js only inlines env vars with that prefix into client-side bundles, and you'll need the publishable key available in both server and client fetches since cart mutations often happen from Client Components.
Choosing How to Call the Store API
You have two viable options, and picking wrong early costs you a rewrite later.
- @medusajs/js-sdk: Medusa's official JS SDK. It wraps the Store and Admin API surfaces with typed methods (sdk.store.product.list, sdk.store.cart.create, and so on), handles the publishable-key header for you, and tracks its own types against your backend version. This is the right default for most projects — less boilerplate, fewer header-mismatch bugs.
- Plain fetch against the REST API: viable if you want zero SDK dependency, need a request shape the SDK doesn't expose cleanly, or are calling from an environment where adding the SDK is awkward (an edge runtime with strict bundle-size limits, for instance). You lose the typed convenience but gain full control over caching directives, which matters more than it sounds once you start tuning fetch's next: { revalidate } and next: { tags } options per request.
A pragmatic middle ground many teams land on: use the SDK for mutations (cart, checkout, customer auth) where correctness matters more than cache tuning, and plain fetch with explicit Next.js cache options for read-heavy product and category queries where you want fine control over revalidation. There's no wrong answer here as long as you're deliberate about it — the failure mode is mixing both without a rule, which produces inconsistent caching behavior across pages that's hard to debug later.
Setting Up the Medusa Client
Install the SDK: npm install @medusajs/js-sdk. Create a single client module and import it everywhere rather than instantiating the SDK per-request — this keeps configuration in one place and makes it trivial to swap the backend URL between environments.
The client needs three things: the backend URL, the publishable key, and a decision about where auth tokens live. For the storefront's anonymous and customer-authenticated flows, Medusa's SDK supports a cookie-based session by default in server environments — appropriate for Server Components and Route Handlers — with a fallback to a local-storage-backed token for pure client-side apps. Since you're on the App Router, prefer the cookie-based session so cart and customer state survive across server-rendered navigations without a client-side rehydration step.
Modules and Regions: Get These Right First
Medusa v2's biggest structural change from v1 is the move to independent modules — product, pricing, inventory, cart, order, region, customer, and so on — each with its own data model and service, composed together rather than living in one monolithic core. For storefront work you mostly consume the effects of this rather than touch it directly, but one consequence matters immediately: prices and availability are region-scoped, and almost every Store API read needs a region_id (or a way to resolve one) to return correct amounts.
Fetch the list of regions once — sdk.store.region.list() — and either let the customer pick one (typical for multi-currency stores) or resolve one automatically from locale/country if you're single-region. Store the chosen region id somewhere durable (a cookie, not just React state) so a page refresh doesn't silently fall back to a default region and show different prices than the ones the customer was just looking at. This is a subtle but real bug class: a PDP that renders a price without a resolved region either errors or, worse, silently returns the first available region's price, which can be a different currency than what the customer expects.
Product Listing (Server Component)
A collection or category page is the simplest place to start because it's a pure read with no cart state. In the App Router, make the page an async Server Component and fetch directly — no useEffect, no client-side loading state, no waterfall:
- Call sdk.store.product.list({ region_id, limit, offset }) (or the equivalent typed fetch) inside the page component, awaited directly in the function body.
- Pass fields to trim the response to what the grid actually renders (thumbnail, title, handle, variant prices) — Medusa v2's Store API supports a fields query param for exactly this, and skipping it means shipping unused JSON like full variant/option trees to every collection page.
- Wrap the fetch in Next.js's caching model deliberately: for a catalog that changes a few times a day, { next: { revalidate: 300 } } (or a matching option on the SDK call) is far cheaper than an uncached request per page view, and still fresh enough that a new product shows up within minutes.
Pagination is offset-based in the Store API (limit/offset, with a count in the response), so a "load more" or numbered pagination UI is a straightforward derived calculation from those three numbers — no cursor bookkeeping required for a typical storefront catalog size.
Product Detail Page
The PDP is where option/variant handling gets real. A product in Medusa v2 has options (e.g. Color, Size) and variants, each variant being a specific combination of option values with its own price and inventory. The UI job is: render option pickers, and on every selection change, resolve the matching variant so you can show its price, its inventory status, and use its id when adding to cart.
Fetch the single product server-side by handle — sdk.store.product.list({ handle, region_id }) returns the matching product with its variants and calculated prices already resolved for the region. Render the static parts (gallery, description, option labels) in the Server Component, and push only the option-selection and add-to-cart interaction into a small Client Component that receives the product's variants as a prop. This split — server for data and layout, client for the one genuinely interactive widget — keeps the PDP fast without turning the whole page into a client bundle.
Variant resolution itself is a small pure function: given the product's variant list and the currently selected option values, find the variant whose options array matches every selected value exactly. Handle the case where a combination has no matching variant (show it as unavailable rather than silently falling back to the first variant) — this is a common polish gap in hand-rolled Medusa storefronts.
Cart: Create, Add, Update
A cart in Medusa v2 is created once per shopping session and its id persisted — typically in an HTTP-only cookie set from a Route Handler, not client-side localStorage, so it survives across server-rendered pages and isn't readable by injected client scripts. The flow:
- On first "add to cart," check for an existing cart id cookie. If none exists, call sdk.store.cart.create({ region_id }) and set the returned cart's id in a cookie from a Route Handler or Server Action.
- Add the line item: sdk.store.cart.createLineItem(cartId, { variant_id, quantity }).
- For quantity changes or removals, use sdk.store.cart.updateLineItem / deleteLineItem against the same cart id.
- Refetch the cart (or use the response from the mutation directly) to re-render totals — Medusa recalculates tax, shipping estimate, and discounts server-side on every mutation, so don't try to compute totals client-side from line items alone.
Use a Server Action for the add-to-cart mutation rather than a client-side fetch to an external API route when you can — it keeps the publishable key and cart-cookie handling entirely server-side, and lets you revalidatePath or revalidateTag the pages that show cart count (typically the header) in the same request. If your header cart indicator is a Client Component reading from a global store instead, that's fine too — just be consistent about which layer owns "cart truth" so you don't end up with the server cart and a stale client copy disagreeing after a mutation.
Checkout
Checkout in Medusa v2 is a sequence of cart updates culminating in an order: set shipping address and email, list and select a shipping option, list and initialize a payment session, then complete the cart. Each step is its own Store API call — sdk.store.cart.update for addresses and email, sdk.store.fulfillment.listCartOptions and sdk.store.cart.addShippingMethod for shipping, sdk.store.payment.initiatePaymentSession for payment, and finally sdk.store.cart.complete to convert the cart into an order.
Two things worth knowing before you build this multi-step flow: payment providers (Stripe and others) are configured as plugins on the Medusa backend, not the storefront — your Next.js code only initiates and confirms a session against whatever provider the backend exposes, it doesn't hold provider secrets. And cart.complete can return either an order or an error object (e.g. inventory went to zero between "add to cart" and checkout) — always branch on the response shape rather than assuming success, since a checkout flow that doesn't handle the error case will otherwise show a broken confirmation page on the least forgiving possible step of the funnel.
Caching Strategy: What to Cache, What Not To
This is the part generic Next.js tutorials gloss over and Medusa-specific ones often get wrong in the other direction. A rough rule of thumb:
- Product listings and PDPs: cache with a moderate revalidate window (minutes, not seconds) since catalog data changes infrequently relative to page-view volume. Tag these fetches (next: { tags: ['products'] }) so you can revalidateTag('products') from a webhook when the backend's product.updated event fires, instead of relying purely on time-based expiry.
- Region and category/collection lists: cache aggressively and for longer — these change even less often than products.
- Cart contents: never cache. Every cart read must reflect the mutation that just happened; treat it as cache: 'no-store' or fetch it fresh via a Server Action response rather than a cached GET.
- Customer/auth-scoped data: never cache across users — this is the one place a caching mistake becomes a real security bug, not just a stale-data annoyance. Keep customer-specific fetches uncached and scoped to the request.
If your backend can fire webhooks (Medusa supports subscribers/event emitters for this), wire a Route Handler that receives product.updated or product.deleted events and calls revalidateTag for the matching tag. That combination — long time-based revalidate as a safety net, tag-based revalidate on the actual event — gets you both a fast storefront and one that doesn't show a deleted product for five minutes after a merchant removes it.
Design and Component Considerations
A functioning Medusa integration and a well-designed storefront are different problems, and it's easy to spend all your effort on the former and ship a PDP that converts poorly. The option-picker and gallery patterns matter more than they look: a variant picker that renders four unlabeled dropdowns reads as generic and untrustworthy next to one with clear option groups and disabled states for unavailable combinations. If you're assembling the UI layer yourself, a solid starting point is designing the PDP, cart drawer, and checkout steps in Figma before writing components, so the option-group hierarchy and trust-signal placement (returns, shipping estimate, security badges) are decided deliberately rather than improvised in code. Our Figma UI kits are built around exactly these commerce patterns — product galleries, multi-group variant pickers, cart and checkout flows — as a design reference you can adapt regardless of what frontend stack renders the final page.
Common Pitfalls Recap
- Publishable key not linked to the sales channel your products belong to — silent empty results, not an error.
- Forgetting region_id on product reads — prices come back wrong, missing, or in an unexpected currency.
- Storing the cart id in client-side localStorage instead of an HTTP-only cookie — breaks server-rendered cart state and is avoidable exposure.
- Caching cart or customer-scoped fetches — stale totals at best, cross-customer data leakage at worst.
- Assuming cart.complete always returns an order — skips handling the inventory/payment-failure branch that real checkout traffic will eventually hit.
- Fetching full product objects everywhere instead of trimming with fields — inflates every collection-page response for no benefit.
Frequently Asked Questions
Do I need the Medusa Next.js starter, or should I build from scratch?
Medusa publishes an official Next.js starter storefront that demonstrates most of the patterns in this guide end to end, and it's a reasonable reference to read even if you build your own from scratch. Building from scratch makes sense when you have specific design or component-architecture requirements the starter doesn't fit; using it as a base makes sense when you want to move fast and are comfortable adapting an existing structure.
Should cart and checkout logic live in Server Actions or API routes?
Server Actions are generally the better fit in the App Router for mutations triggered from a form or button — they avoid hand-rolling a fetch client on the browser side and keep the publishable key and cart cookie server-side. Reach for a Route Handler instead when you need a stable HTTP endpoint for something external to call, like a payment provider webhook or a backend event subscriber.
How do I handle multi-currency and multi-region storefronts?
Fetch the region list once, let the customer pick (or infer one from locale), and persist the chosen region id in a cookie. Every product and cart fetch afterward should pass that region id explicitly rather than relying on a default, since Medusa resolves prices per region and a missing or wrong region id is the most common cause of incorrect prices in production.
Is Medusa v2 production-ready for a real store, or should I wait?
Medusa v2 is the current stable major version and is what new projects should build on — v1 is the legacy line at this point. As with any self-hosted platform, production-readiness is really a question of your own operational maturity (backups, monitoring, a tested deploy pipeline) rather than the framework's, so budget for that infrastructure work alongside the storefront build.