---
title: "Complete Medusa + Next.js Integration Guide (Payload CMS)"
slug: "medusa-nextjs-integration-payload-cms"
published: "2026-04-28"
updated: "2026-05-02"
validated: "2026-05-02"
categories:
  - "Next.js"
tags:
  - "Medusa Next.js integration"
  - "@medusajs/js-sdk"
  - "Next.js Server Component"
  - "Payload CMS integration"
  - "NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY"
  - "region_id pricing"
  - "next.config remotePatterns"
  - "medusa-storefront monorepo"
  - "sdk.store.product.list"
  - "Medusa image hostname"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "medusa v2"
  - "next.js 15"
  - "payload cms v3"
  - "@medusajs/js-sdk"
  - "postgresql"
status: "stable"
llm-purpose: "Medusa Next.js integration: a step-by-step guide to use @medusajs/js-sdk with Payload CMS—configure env vars, region_id, and image hostnames, then fetch…"
llm-prereqs:
  - "Access to Medusa v2"
  - "Access to Next.js 15"
  - "Access to Payload CMS v3"
  - "Access to @medusajs/js-sdk"
  - "Access to PostgreSQL"
llm-outputs:
  - "Completed outcome: Medusa Next.js integration: a step-by-step guide to use @medusajs/js-sdk with Payload CMS—configure env vars, region_id, and image hostnames, then fetch…"
---

**Summary Triples**
- (Complete Medusa + Next.js Integration Guide (Payload CMS), focuses-on, Medusa Next.js integration: a step-by-step guide to use @medusajs/js-sdk with Payload CMS—configure env vars, region_id, and image hostnames, then fetch…)
- (Complete Medusa + Next.js Integration Guide (Payload CMS), category, general)

### {GOAL}
Medusa Next.js integration: a step-by-step guide to use @medusajs/js-sdk with Payload CMS—configure env vars, region_id, and image hostnames, then fetch…

### {PREREQS}
- Access to Medusa v2
- Access to Next.js 15
- Access to Payload CMS v3
- Access to @medusajs/js-sdk
- Access to PostgreSQL

### {STEPS}
1. Install the Medusa JS SDK
2. Create a single SDK client
3. Configure environment variables
4. Allow Medusa image hostnames
5. Build the shop as a Server Component
6. Extend the pattern for other APIs

<!-- llm:goal="Medusa Next.js integration: a step-by-step guide to use @medusajs/js-sdk with Payload CMS—configure env vars, region_id, and image hostnames, then fetch…" -->
<!-- llm:prereq="Access to Medusa v2" -->
<!-- llm:prereq="Access to Next.js 15" -->
<!-- llm:prereq="Access to Payload CMS v3" -->
<!-- llm:prereq="Access to @medusajs/js-sdk" -->
<!-- llm:prereq="Access to PostgreSQL" -->
<!-- llm:output="Completed outcome: Medusa Next.js integration: a step-by-step guide to use @medusajs/js-sdk with Payload CMS—configure env vars, region_id, and image hostnames, then fetch…" -->

# Complete Medusa + Next.js Integration Guide (Payload CMS)
> Medusa Next.js integration: a step-by-step guide to use @medusajs/js-sdk with Payload CMS—configure env vars, region_id, and image hostnames, then fetch…
Matija Žiberna · 2026-04-28

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

```bash
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.

```ts
// 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`:

```ini
# 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:

```bash
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:

```bash
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:

```ts
// 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.

```tsx
// 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()`:

```ts
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

## LLM Response Snippet
```json
{
  "goal": "Medusa Next.js integration: a step-by-step guide to use @medusajs/js-sdk with Payload CMS—configure env vars, region_id, and image hostnames, then fetch…",
  "responses": [
    {
      "question": "What does the article \"Complete Medusa + Next.js Integration Guide (Payload CMS)\" cover?",
      "answer": "Medusa Next.js integration: a step-by-step guide to use @medusajs/js-sdk with Payload CMS—configure env vars, region_id, and image hostnames, then fetch…"
    }
  ]
}
```