3 MIN READ

The Medusa JS SDK: Querying, Auth and the fields Parameter

The SDK is thin on purpose. What it handles, how authentication differs between store and admin, and why the fields parameter is the difference between a fast storefront and a slow one.

BY ANANYA IYERUPDATED
Illustration for “The Medusa JS SDK: Querying, Auth and the fields Parameter” — Storefront

@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

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!,
  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.

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

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

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

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

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

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

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

ts
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

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

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

[ Keep reading ]