@medusajs/js-sdk is a typed wrapper over the REST API. It handles auth headers, the publishable key, query serialisation and response typing, and deliberately does nothing else — no caching, no state, no opinions.
Which means the two things worth learning are authentication and the fields parameter. The second one is where storefront performance is won or lost.
The client
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!,
debug: process.env.NODE_ENV === "development",
})The publishable key scopes requests to sales channels. It is designed to be public — it grants no privileged access — but every Store API request needs it, and a missing key produces a 400 that does not obviously say so.
fields is the whole performance story
By default, list endpoints return a conservative projection. The two things people discover the hard way: prices are not included by default, and relations are not expanded.
// Returns products with no prices. A surprising number of bug reports start here.
const { products } = await sdk.store.product.list({ limit: 12 })
// Returns what a product grid actually needs.
const { products } = await sdk.store.product.list({
limit: 12,
fields: "id,title,handle,thumbnail,*variants.calculated_price",
})The syntax:
| Prefix | Meaning |
|---|---|
| `id,title` | Only these top-level fields |
| `*variants` | Expand the relation with its default fields |
| `*variants.calculated_price` | Expand a nested relation |
| `+metadata` | Add to the defaults rather than replacing them |
| `-description` | Remove from the defaults |
Ask for exactly what you render. On a 24-product grid, the difference between the default projection and a tight one is routinely 200KB and 150ms.
calculated_price requires a region or country context, or it comes back null:
const { products } = await sdk.store.product.list({
region_id: region.id,
fields: "*variants.calculated_price",
})That is the second most common "prices are missing" cause, after forgetting fields entirely.
Store authentication
Anonymous browsing needs only the publishable key. Customer accounts add a JWT:
"use server"
import { cookies } from "next/headers"
import { sdk } from "@/lib/medusa"
export async function login(email: string, password: string) {
const token = await sdk.auth.login("customer", "emailpass", { email, password })
const store = await cookies()
store.set("_medusa_jwt", token as string, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 7,
})
}
export async function getCustomer() {
const token = (await cookies()).get("_medusa_jwt")?.value
if (!token) return null
try {
const { customer } = await sdk.store.customer.retrieve(
{},
{ authorization: `Bearer ${token}` },
)
return customer
} catch {
return null
}
}The token goes in an httpOnly cookie, and every authenticated call passes it explicitly. Do not put a customer JWT in localStorage.
Admin authentication
Server-side only — the admin API grants full access:
const token = await sdk.auth.login("user", "emailpass", {
email: process.env.MEDUSA_ADMIN_EMAIL!,
password: process.env.MEDUSA_ADMIN_PASSWORD!,
})
const { products } = await sdk.admin.product.list(
{ limit: 50 },
{ authorization: `Bearer ${token}` },
)If you find yourself reaching for the admin API from a storefront, stop. Expose a purpose-built custom store route instead, returning only what that screen needs.
Errors
The SDK throws on non-2xx with a FetchError carrying status and the parsed body:
import { FetchError } from "@medusajs/js-sdk"
try {
await sdk.store.cart.createLineItem(cartId, { variant_id, quantity })
} catch (error) {
if (error instanceof FetchError && error.status === 400) {
return { error: error.message } // e.g. insufficient inventory
}
throw error
}Distinguish the 400s a customer caused — out of stock, invalid promotion — from the 500s you caused. Showing "Something went wrong" for "only 2 left in stock" is a conversion problem.
No caching, on purpose
The SDK issues fetches. Caching belongs to your framework:
import { unstable_cache } from "next/cache"
export const listProducts = unstable_cache(
async (regionId: string) =>
sdk.store.product.list({
region_id: regionId,
fields: "id,title,handle,thumbnail,*variants.calculated_price",
limit: 24,
}),
["products-grid"],
{ tags: ["products"], revalidate: 3600 },
)Patterns for tagging and revalidation in the Next.js storefront guide.
Practical guidance
Wrap the SDK in a data layer. One directory owns every call. Endpoint changes stay contained.
Audit fields on every list endpoint before launch. It is the fastest performance work available.
Never expose admin credentials to the browser. Custom store routes exist for this.
Pass region_id everywhere prices are shown. Missing it silently nulls the price.
Pagination
List endpoints take limit and offset and return count, which is everything you need for either pagination style:
export async function listCategoryProducts(categoryId: string, page: number, regionId: string) {
const limit = 24
const { products, count } = await sdk.store.product.list({
category_id: [categoryId],
region_id: regionId,
fields: "id,title,handle,thumbnail,*variants.calculated_price",
limit,
offset: (page - 1) * limit,
order: "-created_at",
})
return { products, count, totalPages: Math.ceil(count / limit) }
}Offset pagination degrades on very deep pages, because the database still walks the skipped rows. For catalogs where anyone genuinely reaches page 40, filter by a cursor — created_at less than the last item's — instead of an offset.
For SEO, every page needs a real URL rather than an infinite scroll with no addressable state. See storefront SEO.
Ordering and filtering
| Parameter | Example |
|---|---|
| `order` | `"-created_at"`, `"title"` — minus for descending |
| `q` | Free-text search across title and description |
| `category_id` | Array of ids |
| `collection_id` | Array of ids |
| `tag_id` | Array of ids |
| `handle` | Exact match, for detail pages |
| `id` | Array — how you re-fetch search results |
The q parameter is a database search and is fine for a small catalog. Past a few thousand products, move discovery to a search engine and use id filtering to re-fetch the hits.
Retries and timeouts
The SDK does not retry, which is correct — a blind retry on a cart mutation can double a line item. Add retries deliberately, only where the operation is safe to repeat:
async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
for (let i = 0; i < attempts; i++) {
try {
return await fn()
} catch (error: any) {
// Never retry a 4xx — the request was wrong, not unlucky.
if (error?.status && error.status < 500) throw error
if (i === attempts - 1) throw error
await new Promise((r) => setTimeout(r, 2 ** i * 200))
}
}
throw new Error("unreachable")
}Wrap reads freely. Wrap writes only when the endpoint is idempotent, and set a timeout on every call so a slow backend degrades into an error page rather than a hanging request.
Most slow headless storefronts are over-fetching, not under-caching. We audit these.
Frequently asked questions
Do I have to use the Medusa JS SDK?
No — the Store and Admin APIs are plain REST and any HTTP client works. The SDK adds typed responses, header handling and query serialisation, which is enough convenience that most teams use it.
Why are product prices null in my API response?
Two usual causes: the `fields` parameter does not request `*variants.calculated_price`, or no region or country context was supplied, so Medusa cannot resolve which price list applies. Pass `region_id` alongside the field selection.
What is the fields parameter in Medusa?
A projection control. It selects top-level fields, expands relations with `*`, adds to defaults with `+` and removes with `-`. Tightening it is the single most effective way to reduce storefront payloads and response times.
How do I authenticate a customer with the SDK?
Call `sdk.auth.login("customer", "emailpass", { email, password })` to get a JWT, store it in an httpOnly cookie, and pass it as an `authorization` header on authenticated requests. Never store the token in localStorage.
Can I use the admin API from my storefront?
No. Admin credentials grant full access and must stay server-side. If a storefront screen needs data the Store API does not expose, add a custom store route that returns exactly that data.
Does the SDK cache responses?
No, deliberately. Caching is left to your framework — Next.js `unstable_cache` with tags, or whatever your stack provides — so invalidation stays under your control.
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.
Multi-Language Medusa Storefronts: Routing, Content and hreflang
Medusa handles currency and tax per region, not translation. How to layer language onto that — URL structure, product content, and the hreflang most stores get wrong.
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.



