- Complete Medusa + Next.js Integration Guide (Payload CMS)
Complete Medusa + Next.js Integration Guide (Payload CMS)
Step-by-step setup using @medusajs/js-sdk, Server Components and Payload CMS—configure env vars, region_id, image…

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
How to Connect Medusa JS to a Next.js Storefront (With Payload CMS)
Last updated: April 2026 | Stack: Medusa v2, Next.js 15, Payload CMS v3, @medusajs/js-sdk
Connecting Medusa to a Next.js storefront comes down to three things: installing the official JS SDK, configuring the right environment variables, and fetching products inside a Server Component. The SDK handles authentication headers automatically, so you never need to write raw fetch() calls against the Medusa REST API. This guide walks through the exact setup I used in a monorepo project where Payload CMS was already running as the content layer and Medusa was added as a parallel commerce concern.
I was building a proof-of-concept for a client who wanted Payload CMS for content management and Medusa for product catalog and cart. The monorepo approach kept the two apps cleanly separated, but wiring up the storefront to actually fetch Medusa products required understanding a few non-obvious pieces — particularly around API keys, region pricing, and image handling. Here is everything that mattered.
The Monorepo Structure
The project has two apps in the same repository:
apps/medusa-backend— the Medusa v2 backend (REST API, business logic, PostgreSQL)apps/medusa-storefront— the Next.js 15 storefront with Payload CMS
The storefront was a Payload CMS site with no awareness of Medusa. The goal was to pull Medusa products into Next.js pages without disrupting Payload's routing or content management.
Step 1: Install the Medusa JS SDK in the Storefront
Medusa ships an official TypeScript SDK (@medusajs/js-sdk) that wraps every store and admin endpoint. The key reason to use it over raw fetch() is automatic header injection. Store routes require an x-publishable-api-key header, and admin routes require session cookies or an Authorization header. The SDK attaches these transparently — you configure the key once and every subsequent call includes it.
Install the SDK into the storefront package only:
pnpm --filter medusa-storefront add @medusajs/js-sdk
Step 2: Create a Single SDK Client Instance
Create one shared SDK instance that every file in the storefront imports. Centralising it here means you update the base URL or API key in a single place.
// File: apps/medusa-storefront/src/lib/medusa.ts
import Medusa from "@medusajs/js-sdk"
export const sdk = new Medusa({
baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL ?? "http://localhost:9000",
publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,
})
The NEXT_PUBLIC_ prefix is required for Next.js to expose these values to both the server-side rendering layer and the browser bundle. Without it, client-side components lose access to the variable.
Step 3: Configure Environment Variables
Add the following to apps/medusa-storefront/.env:
# Medusa backend URL
NEXT_PUBLIC_MEDUSA_BACKEND_URL=http://localhost:9000
# From Medusa admin → Settings → API Keys
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=pk_...
# From Medusa admin → Settings → Regions, or via the API below
NEXT_PUBLIC_MEDUSA_REGION_ID=reg_...
How to Get the Publishable API Key
The publishable key is created during backend seeding (see apps/medusa-backend/src/scripts/seed/api-key.ts). Retrieve it from the Medusa admin UI under Settings → API Keys, or call the API directly:
curl http://localhost:9000/store/regions \
-H "x-publishable-api-key: pk_YOUR_KEY_HERE"
Why the Region ID Is Not Optional
Medusa stores prices per region. Omitting region_id from a product list request means the calculated_price field on every variant returns null. The storefront then shows nothing for price, or falls back to "Price on request". Passing the region ID tells Medusa which currency and pricing rules to apply against each variant.
Fetch all region IDs from your backend:
curl http://localhost:9000/store/regions \
-H "x-publishable-api-key: pk_YOUR_KEY_HERE"
Step 4: Allow Medusa Image Hostnames in Next.js
Product thumbnails are served from the Medusa backend — localhost:9000 in development. Next.js blocks external images unless the hostname is explicitly declared in next.config.ts.
Add the Medusa backend to the remotePatterns array:
// File: apps/medusa-storefront/next.config.ts
images: {
remotePatterns: [
{
hostname: "localhost",
protocol: "http",
port: "9000",
},
// ... existing patterns
],
},
In production, replace localhost with your actual Medusa backend hostname. Without this, the <Image> component throws a hostname error and images fail silently.
Step 5: Create the Shop Page as a Server Component
Rather than modifying the Payload CMS home page — which is a catch-all route managing all CMS-authored content — a dedicated /shop route keeps the two systems independent. Overwriting the Payload catch-all would break every CMS-managed page. A parallel route avoids that entirely.
// File: apps/medusa-storefront/src/app/(frontend)/shop/page.tsx
import { sdk } from "@/lib/medusa"
import Image from "next/image"
export const metadata = {
title: "Products",
}
export default async function ShopPage() {
let products: Awaited<ReturnType<typeof sdk.store.product.list>>["products"] = []
try {
const { products: fetched } = await sdk.store.product.list({
limit: 12,
region_id: process.env.NEXT_PUBLIC_MEDUSA_REGION_ID,
})
products = fetched
} catch (err) {
console.error("Failed to fetch Medusa products:", err)
}
return (
<main className="max-w-6xl mx-auto px-4 py-16">
<h1 className="text-3xl font-bold mb-8">Products</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product) => (
<div key={product.id} className="border rounded-lg overflow-hidden">
{product.thumbnail && (
<Image
src={product.thumbnail}
alt={product.title}
width={400}
height={300}
className="w-full object-cover"
/>
)}
<div className="p-4">
<h2 className="font-semibold text-lg">{product.title}</h2>
</div>
</div>
))}
</div>
</main>
)
}
This is a Server Component, so data is fetched at request time on the server without any client-side loading state. The try/catch around the SDK call means the page renders an empty grid when the Medusa backend is down, rather than throwing a 500. For a proof-of-concept or low-traffic shop page, this pattern is the most maintainable starting point — no React Query, no global state, no client bundle cost.
How the Pieces Fit Together
Browser / Next.js Server
│
│ sdk.store.product.list({ region_id })
▼
@medusajs/js-sdk
│ injects x-publishable-api-key automatically
▼
Medusa Backend (localhost:9000)
│ queries products + calculates prices for region
▼
PostgreSQL (port 25433)
The SDK is the only layer that talks to the Medusa REST API. Everything else in the storefront imports from @/lib/medusa and calls typed methods.
Extending to Other Medusa Data
The same pattern — import sdk, call a typed method, wrap in try/catch inside a Server Component — applies to everything else in the Medusa store API:
| Data | SDK method |
|---|---|
| Product list | sdk.store.product.list() |
| Single product | sdk.store.product.retrieve(id) |
| Collections | sdk.store.collection.list() |
| Cart creation | sdk.store.cart.create() |
| Cart retrieval | sdk.store.cart.retrieve(id) |
| Customer | sdk.store.customer.retrieve() |
For custom API routes you have built on the Medusa backend, use sdk.client.fetch():
const data = await sdk.client.fetch("/store/my-custom-route", {
method: "POST",
body: { someField: "value" },
})
Pass plain objects to the body field. The SDK serializes them to JSON internally — calling JSON.stringify() yourself produces a double-serialized string that the backend cannot parse.
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
Using raw fetch() instead of the SDK | Missing auth headers → 401 errors on every request | Use sdk.* methods or sdk.client.fetch() |
JSON.stringify() on the request body | Double-serialized payload → backend parse errors | Pass plain objects; the SDK serializes automatically |
Omitting region_id from product list | calculated_price is null → no prices rendered | Always pass a region_id |
Medusa image hostname missing from remotePatterns | Next.js blocks image loading silently | Declare the hostname in next.config.ts |
| Modifying the Payload catch-all route for Medusa | Breaks all CMS-managed pages | Create a dedicated /shop route instead |
FAQ
Can I use React Query or SWR for Medusa data instead of Server Components?
Yes, and it makes sense for interactive features like cart state or real-time inventory. For a product listing page with no user-specific data, Server Components are simpler and load faster. Start with the Server Component pattern and add client-side fetching only where the UI genuinely needs it.
Do I need a separate publishable API key per environment?
Medusa recommends it. Create one key for development and a separate one for staging and production. This prevents production traffic from appearing in development analytics and keeps API key rotation scoped to one environment at a time.
What happens if the Medusa backend is unreachable during a Next.js build?
If you use export const dynamic = "force-dynamic" (or fetch data inside the component without cache), the page fetches at request time — a backend outage affects individual requests rather than the build. If you fetch at build time using static generation, a backend outage will fail the build. Prefer request-time fetching for commerce data that changes frequently.
How do I show prices correctly for multiple regions?
Fetch the region from a cookie, URL parameter, or geolocation middleware, and pass the resolved region_id to every SDK call. Medusa calculates prices per region on the backend — the storefront only needs to pass the correct ID.
Can this pattern work without Payload CMS?
Completely. The /shop route and the SDK client are standard Next.js — Payload CMS is invisible to them. The only Payload-specific decision in this guide is avoiding the catch-all route. Any Next.js app structure works.
Wrapping Up
Connecting Medusa to a Next.js storefront requires four concrete things: the official SDK installed in the right package, a single client instance reading from environment variables, a region_id on every product fetch, and the Medusa image hostname added to next.config.ts. The Server Component pattern keeps the integration simple and avoids unnecessary client-side overhead for a product listing page.
If you are building a more complete storefront — cart, checkout, customer auth — the same SDK instance and the same approach scale to all of it. The typed methods keep everything consistent as the surface area grows.
Let me know in the comments if you run into any issues, and subscribe if you want more practical guides on Medusa and Next.js integrations.
Thanks, Matija
Frequently Asked Questions
Comments
No comments yet
Be the first to share your thoughts on this post!