npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@elasticpath/plasmic-ep-commerce-elastic-path

v0.8.0

Published

Plasmic registration calls for elastic path commerce provider

Readme

@elasticpath/plasmic-ep-commerce-elastic-path

Elastic Path commerce components for Plasmic. Server-side auth, SSR product data, cart operations — all wired through a Better Auth-aligned session pattern.

Quick Start (4 files)

1. Create the auth instance

// lib/ep-auth.ts
import { createEpAuth } from "@elasticpath/plasmic-ep-commerce-elastic-path/server";

export const epAuth = createEpAuth({
  // Same values configured on the EP Provider in Plasmic Studio
  clientId: "your-ep-client-id",
  host: "https://useast.api.elasticpath.com",

  // Session cookie secret. Read it straight from the environment — no `!`
  // and no fallback: a missing value must fail loudly in production, not
  // silently become a guessable key.
  secret: process.env.CHECKOUT_SESSION_SECRET,

  // Optional: checkout session encryption
  checkout: { sessionSecret: process.env.CHECKOUT_SESSION_SECRET! },

  // Optional: payment adapters
  adapters: { stripe: { secretKey: process.env.STRIPE_SECRET_KEY! } },

  // Optional: custom API route prefix (default: /api/ep)
  // basePath: "/api/store",

  // Dead config, read by no code. `sessionCartResolver` replaces it, and it
  // is removed in the breaking release.
  // cartMergeStrategy: "replace",

  // Optional: choose the shopper's cart when they sign in or switch
  // organisation. See "Which cart wins at sign-in" below.
  // sessionCartResolver: ({ guestCartId, accountCarts }) => ...,
});

2. Mount the catch-all API route

App Router:

// app/api/ep/[...path]/route.ts
import { createEpAuthRoutes } from "@elasticpath/plasmic-ep-commerce-elastic-path/server";
import { epAuth } from "@/lib/ep-auth";

export const { GET, POST } = createEpAuthRoutes(epAuth);

Pages Router:

// pages/api/ep/[...path].ts
import type { NextApiRequest, NextApiResponse } from "next";
import { createEpAuthRoutes } from "@elasticpath/plasmic-ep-commerce-elastic-path/server";
import { epAuth } from "@/lib/ep-auth";

const handlers = createEpAuthRoutes(epAuth);

export const config = { api: { bodyParser: false } };

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  const method = req.method?.toUpperCase() as "GET" | "POST";
  const fn = handlers[method];
  if (!fn) return res.status(405).end();

  // The handlers take a WHATWG Request, so rebuild one from the Node req.
  const url = `${process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"}${req.url}`;
  const body =
    method === "GET"
      ? undefined
      : await new Promise<string>((resolve) => {
          let raw = "";
          req.on("data", (chunk) => (raw += chunk));
          req.on("end", () => resolve(raw));
        });

  const response = await fn(
    new Request(url, {
      method,
      headers: req.headers as Record<string, string>,
      body,
    })
  );

  // Set-Cookie must be forwarded as a list — setHeader would collapse it.
  res.setHeader("Set-Cookie", response.headers.getSetCookie());
  response.headers.forEach((value, key) => {
    if (key.toLowerCase() !== "set-cookie") res.setHeader(key, value);
  });
  res.status(response.status).send(await response.text());
}

3. Wire the page with session

App Router:

// app/[[...catchall]]/page.tsx
import { epAuth } from "@/lib/ep-auth";
import {
  PlasmicRootProvider,
  PlasmicComponent,
  extractPlasmicQueryData,
} from "@plasmicapp/loader-nextjs";
import { PLASMIC } from "@/plasmic-init";
import { cookies } from "next/headers";

export default async function Page({ params }: { params: { catchall?: string[] } }) {
  const pagePath = "/" + (params.catchall?.join("/") ?? "");
  const plasmicData = await PLASMIC.fetchComponentData(pagePath);
  if (!plasmicData) return <div>Not found</div>;

  // Resolve EP session from cookies (returning visitor) or OAuth (first visit)
  const cookieStore = await cookies();
  const session = await epAuth.api.getSession({
    cookies: Object.fromEntries(
      cookieStore.getAll().map((c) => [c.name, c.value])
    ),
  });

  // SSR: extract query data. The session stays server-side — it is never
  // handed to globalContextsProps, which Plasmic serializes into the HTML.
  const queryData = await extractPlasmicQueryData(
    <PlasmicRootProvider loader={PLASMIC} prefetchedData={plasmicData}>
      <PlasmicComponent component={pagePath} />
    </PlasmicRootProvider>
  );

  // Set ep_token cookie for subsequent visits (no-op if token came from cookie)
  session.commitCookies({
    appendHeader(name, value) {
      cookieStore.set(name, value);
    },
  });

  return (
    <PlasmicRootProvider
      loader={PLASMIC}
      prefetchedData={plasmicData}
      prefetchedQueryData={queryData}
    >
      <PlasmicComponent component={pagePath} />
    </PlasmicRootProvider>
  );
}

Pages Router:

// pages/[[...catchall]].tsx
import type { GetServerSideProps } from "next";
import { epAuth } from "@/lib/ep-auth";
import {
  PlasmicRootProvider,
  PlasmicComponent,
  ComponentRenderData,
  extractPlasmicQueryData,
} from "@plasmicapp/loader-nextjs";
import { PLASMIC } from "@/plasmic-init";

export const getServerSideProps: GetServerSideProps = async ({ req, res, params }) => {
  const pagePath = "/" + ((params?.catchall as string[])?.join("/") ?? "");
  const plasmicData = await PLASMIC.fetchComponentData(pagePath);
  if (!plasmicData) return { notFound: true };

  // Resolve EP session from cookies (returning visitor) or OAuth (first visit)
  const session = await epAuth.api.getSession({
    cookies: req.cookies as Record<string, string>,
  });

  // SSR: extract query data. The session stays server-side — it is never
  // handed to globalContextsProps, which Plasmic serializes into the HTML.
  const queryData = await extractPlasmicQueryData(
    <PlasmicRootProvider loader={PLASMIC} prefetchedData={plasmicData}>
      <PlasmicComponent component={pagePath} />
    </PlasmicRootProvider>
  );

  // Set ep_token cookie for subsequent visits (no-op if token came from cookie)
  session.commitCookies({
    appendHeader(name: string, value: string) {
      res.appendHeader(name, value);
    },
  });

  return {
    props: { plasmicData, queryData, pagePath },
  };
};

export default function Page({
  plasmicData,
  queryData,
  pagePath,
}: {
  plasmicData: ComponentRenderData;
  queryData: Record<string, any>;
  pagePath: string;
}) {
  return (
    <PlasmicRootProvider
      loader={PLASMIC}
      prefetchedData={plasmicData}
      prefetchedQueryData={queryData}
    >
      <PlasmicComponent component={pagePath} />
    </PlasmicRootProvider>
  );
}

4. Environment variables

# .env.local — only genuine secrets, no EP credentials
CHECKOUT_SESSION_SECRET=your-32-char-secret-key-here
STRIPE_SECRET_KEY=sk_test_...

# Optional: extra origins permitted to act as the shopper. One list feeds
# auth, the proxy's CORS reflection and the origin gate — add your Studio
# origin here for cross-origin preview.
BETTER_AUTH_TRUSTED_ORIGINS=https://studio.example.com

# Optional: server log level. Unset, the server logs warnings and errors.
# Takes the browser's EP_DEBUG values: "*", "silent", "error", "warn:Pay".
EP_DEBUG=warn

In production createEpAuth refuses to serve when the secret is missing, is a known example placeholder, or is under 32 characters. Anything other than NODE_ENV=development / test counts as production, so preview deployments are held to the same bar; the check stands down during next build so build-then-inject-env pipelines still build.

Two options tighten the deployment further:

| Option | Default | Use when | | --- | --- | --- | | trustedOrigins | the app's own origin | another origin must act as the shopper (e.g. Studio preview) | | hostAllowlist | Elastic Path Composable Commerce regions, *.epcloudops.com, the integration host, and loopback outside production | this store's Elastic Path API is served from a custom domain |

The EP API host named in the Plasmic bundle is checked against the EP host allow-list: the defaults, plus hostAllowlist, plus the comma-separated EP_HOST_ALLOWLIST environment variable. Your entries extend the defaults. createEpAuth resolves the list once, hands it to resolveConfig, and exposes it as epAuth.config.hostAllowlist for any other caller of extractEpProviderConfig. A host off the list is logged and ignored, and the session falls back to the host passed to createEpAuth (ADR-0006).

Architecture

Token Lifecycle

First visit:
  page.tsx → epAuth.api.getSession() → OAuth with clientId → access token
  → buildEpCtx() → withEpSession() → Server Queries SSR product data
  → commitCookies() → httpOnly ep_token cookie set

Returning visit:
  page.tsx → epAuth.api.getSession() → reads ep_token cookie → access token
  → buildEpCtx() → withEpSession() → Server Queries SSR → zero OAuth calls

The session's access token stays on the server:

  1. getSession() reads or mints it, then writes it to an httpOnly cookie
  2. buildEpCtx() puts it on an EpCtx, which withEpSession() publishes through AsyncLocalStorage
  3. Server Queries and the /api/ep proxy routes read it via getCurrentEpSession() and call Elastic Path directly

providerProps() returns {}. Whatever it returned would be handed to globalContextsProps and serialized into the page HTML, so it never carries a credential.

Two token surfaces today, one from ADR-0003

Browser-originated catalog reads currently go through the Elastic Path SDK client, which mints its own anonymous token from the public clientId and holds it in the JS heap. So the storefront has two Elastic Path identities: the session's on the server, an anonymous one in the browser.

Earlier releases described the browser token's in-memory custody as a security property. It is not one, and that claim is retired. An Elastic Path implicit token is public by construction — anyone holding the client_id can mint one — so where it is kept protects nothing. It is also not read-only: it can write to /carts and /checkout.

What is worth protecting is the account token, which carries shopper identity and unlocks order history, account and member records, and addresses. This package keeps that server-side and never hands it to the browser.

ADR-0003 decides the direction: one session, no Elastic Path credential in the browser at all, and every browser-originated call carrying identity routed through the storefront's own origin. The browser client is removed as part of that work; the ADR is the reference for what the surface becomes and why.

Two platform facts to design against in the meantime:

  • A cart id is the entire access boundary on a cart. GET /v2/carts/{id} succeeds for any known id under any token, and an unknown id creates rather than 404s. Keep cart ids in server custody; storefront-side verification is the whole defence, not a second layer.
  • Account association is discoverability, not access control. The relationship is an appendable set, so any account that learns a cart id can attach itself and thereafter enumerate that cart.

next dev is an exception. Next's RSC debug instrumentation serializes a server component's local variables — including the EP session — into the flight payload, so the access token is readable in the page source under next dev. It is absent from next build output, and nothing this package does can suppress it. Treat a dev server as carrying a live shopper credential: don't run one on a shared host or against production Elastic Path credentials.

API Routes

createEpAuthRoutes(epAuth) mounts the auth handler:

| Method | Path | Description | |--------|------|-------------| | POST | {basePath}/ep/anonymous | Mint an anonymous session | | POST | {basePath}/ep/refresh | Rotate the EP token | | POST | {basePath}/ep/cart | Persist epCartId on the session | | POST | {basePath}/ep/account/login | Sign an account member in | | POST | {basePath}/ep/account/roster | Read the accounts the member belongs to | | POST | {basePath}/ep/account/select | Select or deselect the account being bought for | | POST | {basePath}/ep/account/roll | Re-mint the account credential before it runs out | | POST | {basePath}/ep/account/logout | Sign the account member out | | GET | {basePath}/get-session | Read the session, minus EP credentials |

Call these through the identity client rather than by hand. The table is the contract the client is built from, not an instruction to write fetch.

The identity client

useEpIdentity() gives a component the identity operations as methods. It passes no URL and no base path, so a component cannot get identity wrong by configuring it wrong:

import { useEpIdentity } from "@elasticpath/plasmic-ep-commerce-elastic-path";

function SignIn() {
  const identity = useEpIdentity();

  async function signIn(username: string, password: string) {
    const { session, accounts } = await identity.login({ username, password });
    // A member of exactly one account is already placed in it. A member of
    // several has nothing selected, and chooses.
    if (session.epAccount) return null;
    return accounts;
  }

  async function chooseAccount(accountId: string) {
    await identity.selectAccount({ accountId });
  }
  ...
}

| Method | Argument | Resolves to | |--------|----------|-------------| | getSession() | — | the session, or null | | signInAnonymously() | — | the session | | refresh() | — | the session | | setCart({ cartId }) | cart id | the session | | login({ username, password, name? }) | credentials | the session, plus the member's organisations | | roster({ limit?, offset? }) | paging | one page of organisations | | selectAccount({ accountId }) | organisation id, or null to deselect | the session | | rollAccount() | — | the session | | logout() | — | the session |

Arguments and results are one declaration, which the endpoints are typed against: renaming a field a method reads is a compile error in the handler, and calling a method wrongly is a compile error at the call site. The session each method resolves to is checked against the allowlist the handler filters every response through, so no method can be typed as returning an Elastic Path credential.

A refused operation throws, carrying the server's own reason:

import { epIdentityErrorCode } from "@elasticpath/plasmic-ep-commerce-elastic-path";

try {
  await identity.roster();
} catch (err) {
  if (epIdentityErrorCode(err) === "account_lapsed") {
    // Tell the shopper their account access ran out, rather than
    // showing them list prices with no signal.
  }
}

Outside React, createEpIdentityClient({ basePath }) builds the same client.

If you mounted the handler somewhere other than /api/ep, hand the page the mount path once and the client finds it — providerProps() carries it, and the shopper context is where the client reads it from:

<PlasmicRootProvider
  loader={PLASMIC}
  prefetchedData={plasmicData}
  globalContextsProps={{ shopperContextProps: session.providerProps() }}
>

providerProps() is serialized into the page HTML, so it carries the mount path and nothing else.

In the Studio canvas the client stays relative, which resolves against the document serving the artboard — the consumer's own app, holding the shopper's cookies, with no CORS involved. It honours window.__epProxyOrigin so it agrees with the server-function client about where the consumer's app is, but reaching the auth handler across origins would also need that handler to reflect CORS with credentials, and it does not.

Account identity

The session holds the authenticated account member and the selected account — the organisation they are buying for — as two separate facts. isAuthenticated reports the member, so a member who belongs to no organisation reads as signed in. While an account is selected, every ep.* server function and every cart route carries EP-Account-Management-Authentication-Token. The checkout-session handlers do not yet — they take their own shopper token on SessionHandlerContext.

POST /ep/account/login takes { username, password }. The server calls Elastic Path's /v2/account-members/tokens itself, so no Elastic Path credential is ever in the browser. It answers with the roster — { accounts: [{ id, name }], total } — alongside the session, so a chooser renders with no second call.

Selection follows from the count. Exactly one account and the member is placed in it; several and none is chosen for them; none at all and the member is signed in anyway, authenticated but permanently unscoped.

POST /ep/account/roster reads the same list later in the session, taking { limit?, offset? }. Elastic Path caps a page at 100 and this forwards that cap rather than imposing a smaller one, so a member in more accounts than one page holds can still reach any of them. total is Elastic Path's own count.

POST /ep/account/select takes { accountId }, or { accountId: null } to deselect. It re-mints the account credential first, then tears down any checkout session and clears the cart pointer, and writes the new account last — so a switch that fails leaves the previous selection exactly as it was. Selecting the account already selected does nothing at all. No password is needed: re-minting runs off the credential the session already holds.

The checkout session it tears down is the one CookieSessionStore holds. A consumer who supplies their own SessionStore must clear it themselves on a switch — the auth handler has no handle on it.

The account credential is re-minted while it has less than an hour left, without the shopper noticing. A shopper idle long enough for it to run out is reported as lapsed rather than quietly returned to list prices, and roster and select answer account_lapsed rather than presenting a dead credential to Elastic Path.

The older { epMemberId, epAccountId, epAccountToken, epAccountExpires } body still works and still verifies the supplied token against Elastic Path. It is removed in the breaking release.

A store whose authentication realm carries more than one password profile must pass passwordProfileId to createEpAuth. Nothing in a profile record marks a default, and signing in against the wrong one fails with Elastic Path's own authentication failed, which says nothing about profiles. With one profile the package finds it.

get-session releases epMemberId, epAccount.{id,name} and epLapsedAccount.{id,name}. The account credential and its expiry are withheld: the response is filtered to an allowlist of paths, so a field added inside epAccount later is withheld by default. epLapsedAccount states that a selection's credential ran out, rather than reverting the shopper to list prices with no signal.

createCartRoutes(epAuth) mounts the cart routes:

| Method | Path | Description | |--------|------|-------------| | GET | {basePath}/cart | Get cart contents | | POST | {basePath}/cart/items | Add item to cart | | PUT | {basePath}/cart/items/:id | Update item quantity | | DELETE | {basePath}/cart/items/:id | Remove item from cart |

Mount the auth handler through createEpAuthRoutes, never better-auth's toNextJsHandler directly: better-auth's /get-session returns the whole session record, and this package keeps the shopper's EP access token on it.

The checkout-session handlers are mounted one route each, under the apiBaseUrl the session provider is given:

| Method | Path | Handler | Description | |--------|------|---------|-------------| | POST | {apiBaseUrl}/checkout/sessions | handleCreateSession | Open a session for the cart | | GET | {apiBaseUrl}/checkout/sessions/current | handleGetSession | Read the session | | PATCH | {apiBaseUrl}/checkout/sessions/current | handleUpdateSession | Merge fields; a changed shipping address requotes | | POST | {apiBaseUrl}/checkout/sessions/current/shipping | handleCalculateShipping | Requote on demand, for example after the cart changes | | POST | {apiBaseUrl}/checkout/sessions/current/pay | handlePay | Place the order and start payment | | POST | {apiBaseUrl}/checkout/sessions/current/resume-payment | handleResumePayment | Resume payment after a customer action | | POST | {apiBaseUrl}/checkout/sessions/current/abandon-payment | handleAbandonPayment | Unlink a failed or cancelled payment | | POST | {apiBaseUrl}/checkout/sessions/current/confirm | handleConfirm | Confirm a gateway action |

A saved shipping address is a quoted address: the update runs the shippingRateResolver in the same write, so a host that mounts only the session route still offers rates. A resolver failure leaves the address saved with no rates; the /shipping route is the retry.

Which cart wins at sign-in

A shopper can arrive at the sign-in form with a cart, and already have one saved on the account they sign in to. Elastic Path merges nothing on its own, and its copy operation adds quantities, so ten saved plus two added becomes twelve. Which cart wins is merchant policy, so the package asks rather than decides.

With no sessionCartResolver configured:

  • the guest cart wins — the one the shopper is looking at when they click log in, and the only choice that cannot silently destroy or inflate what they just built;
  • with no guest cart, the account's most recently updated cart is adopted;
  • the losing cart is never deleted;
  • switching organisation never carries the previous organisation's cart across, because its lines carry that organisation's prices.

To decide it yourself, configure the hook once on createEpAuth:

export const epAuth = createEpAuth({
  clientId: "your-ep-client-id",
  host: "https://useast.api.elasticpath.com",
  secret: process.env.CHECKOUT_SESSION_SECRET,

  async sessionCartResolver({ trigger, guestCartId, accountCarts, accountId }) {
    // `trigger` is "login" or "accountSwitch". `guestCartId` is null at a
    // switch. `accountCarts` carries { id, name, createdAt, updatedAt } —
    // no line items; read them yourself if your rule needs them.
    // `accountCarts` is newest-first, and may be empty.
    if (!guestCartId) {
      return { keep: accountCarts[0].id };
    }
    return { keep: guestCartId };
  },
});

The hook runs after the account swap, under the shopper's new identity, so ep.* server functions and raw fetch calls both act as the signed-in member of that organisation. That is how a rule like take-the-higher-quantity is written: read both carts, write the lines you want, then return the id of the cart you wrote to.

trigger is "accountSwitch" only when the shopper changed organisation without signing in again — so guestCartId is always null when it is. Every other case is "login": signing in, and choosing an organisation while acting for none, including a member of several picking their first.

accountCarts is one page, most recently updated first, so accountCarts[0] is the organisation's newest cart. It can be empty. Elastic Path ignores sort on its cart list, so the order is applied here, and an organisation holding more carts than one page inside the store's expiry window can have a more recent one the hook never sees.

keep must name a cart the hook was offered — guestCartId or one of accountCarts. A hook that throws, or names anything else, never blocks the sign-in: the default applies and the failure is logged. There is no package timeout; your platform's request timeout is the bound. A partial write is yours to avoid — decide first and write last, or make the writes safe to repeat.

Signing out clears the cart pointer, so the next person on that browser does not inherit the previous shopper's cart. Signing in, like switching organisation, tears down any checkout session in flight: it was priced and addressed for the shopper who is no longer the one here.

The hook is handed no cart id on the session scope, only in its input. Which cart is the session cart is the question it is answering, so ep.* calls inside it name the cart they mean rather than defaulting to one.

Cookie Architecture

| Cookie | Contents | Purpose | |--------|----------|---------| | better-auth.session_token | Signed session id | Session identity | | better-auth.session_data | JWE — EP access token, client id, host, cart id | Everything the server needs to act as the shopper |

Both are HttpOnly; SameSite=Lax; Path=/, Secure in production. The EP access token, the account fields and the cart id all live inside the encrypted session_data payload — there is no separate ep_token, ep_account or ep_cart cookie.

Server-Side Rendering

Product data server-renders through Studio Server Queries against the EP custom functions — see Studio Server Queries (SSR) below. buildEpCtx() + withEpSession() carry the session; each ep.* function reads it via getCurrentEpSession().

Data flows into prefetchedQueryData → client hydration. Product data SSRs for SEO. The token never enters the cache.

Better Auth Alignment

The API follows Better Auth conventions:

| EP Commerce | Better Auth | |-------------|-------------| | createEpAuth() | betterAuth() | | epAuth.api.getSession(req) | auth.api.getSession(req) | | createEpAuthRoutes(epAuth) | toNextJsHandler(auth) | | session.user / session.session | session.user / session.session |

Studio Server Queries (SSR)

For SSR'd product data — where the initial HTML payload contains real product names, prices, and images — wire up Plasmic's Server Queries against the EP custom functions exposed by this package. The EP test project's PDP renders end-to-end this way; see examples/ep-commerce-app-router/ for a working reference.

1. Loader configuration

// plasmic-init.ts
import { initPlasmicLoader } from "@plasmicapp/loader-nextjs/react-server-conditional";

export const PLASMIC = initPlasmicLoader({
  projects: [{ id: "...", token: "..." }],
  preview: true,
  // REQUIRED: without this the loader fetches the Pages Router bundle, which
  // omits per-page `serverQueriesExecFuncFileName` metadata and Server Queries
  // never execute.
  platformOptions: { nextjs: { appDir: true } },
});

2. Register the EP custom functions

// plasmic-register.ts
import { PLASMIC } from "./plasmic-init";
import { registerEpCustomFunctions } from "@elasticpath/plasmic-ep-commerce-elastic-path/server";

registerEpCustomFunctions(PLASMIC);

This registers the read functions in the ep namespace, callable from Studio's Server Query builder:

  • ep.getProduct({ id }) — single product by product reference: the product's slug, or its ID when it has none. Bind id to $ctx.params.slug; the links EP Search Hits builds carry exactly that value. A reference that names no product returns null.
  • ep.getCart() — current cart contents.
  • ep.getProductList({ limit?, search?, categoryId?, sort? }) — a flat array of products. categoryId is a hierarchy node ID; it reads that node's products rather than filtering the whole catalog.
  • ep.getProductPage({ limit?, offset?, search?, categoryId?, sort? }) — one page of products with the total count, in Elastic Path's envelope: data, plus meta.results.total and meta.page. Bind it to EP Product List Provider's Products (pre-fetched) prop to server-render a listing. Prefer this over getProductList whenever the page has pagination controls — the flat array carries no total, so ranges and next/previous cannot be computed.
  • ep.getRelatedProducts({ productId, relationshipSlug, limit? }) — products linked by an EP custom relationship.
  • ep.getStock({ productIds, locationIds? }) — multi-location stock, keyed by product ID. Counts are plain numbers, because the value crosses JSON.stringify twice. A product whose stock is unreadable comes back with zero counts rather than failing the batch.
  • ep.getLocations({ type? }) — the inventory locations.
  • ep.getBundleOptionProducts({ productIds }) — the products a bundle offers as options, keyed by product ID, each the package's own product shape with images joined and prices carrying all four members.
  • ep.getBaseProducts({ productIds }) — the given products with their variations and childProducts. A product that is not a base product comes back with an empty childProducts; one the catalog does not return is omitted, because absent and purchasable-on-its-own are different answers.
  • ep.configureBundle({ bundleId, selectedOptions }) — re-prices a bundle for a set of option selections and returns Elastic Path's configured-bundle payload. Throws on failure: a configurator showing a stale total is worse than one showing an error.
  • ep.multiSearch({ searches, include? }) — a catalog multi-search, returned as-is. Pass include: ["main_image"] to get hit images: Elastic Path omits the top-level included block entirely unless it is asked for, and that block is what each hit's relationships.main_image resolves against. The response is passed through rather than reshaped so the block survives. It throws when the search fails: an empty result is a plausible correct answer here, so failing soft would render an outage as a no-results page.

Auth is not an argument. The session (accessToken, clientId, host, cartId, …) is propagated through AsyncLocalStorage — see step 3.

3. Wrap Server Queries in withEpSession

// app/[[...catchall]]/page.tsx
import { PLASMIC } from "@/plasmic-init";
import { PlasmicClientRootProvider } from "@/plasmic-init-client";
import { PlasmicComponent } from "@plasmicapp/loader-nextjs";
import {
  buildEpCtx,
  withEpSession,
} from "@elasticpath/plasmic-ep-commerce-elastic-path/server";
import { epAuth } from "@/lib/ep-auth";
import { cookies } from "next/headers";

export default async function PlasmicLoaderPage({ params, searchParams }) {
  const resolvedParams = await params;
  const plasmicPath = resolvedParams.catchall ? `/${resolvedParams.catchall.join("/")}` : "/";
  const prefetchedData = await PLASMIC.maybeFetchComponentData(plasmicPath);
  if (!prefetchedData) return null;
  const pageMeta = prefetchedData.entryCompMetas[0];

  // Resolve session (anonymous OAuth on first visit, cookie on return).
  const cookieStore = await cookies();
  const session = await epAuth.api.getSession({
    cookies: Object.fromEntries(cookieStore.getAll().map((c) => [c.name, c.value])),
  });

  // Compose the EP session — auth + cart context for server-side EP calls.
  const epCtx = buildEpCtx(session);

  // Run Studio Server Queries inside an EP session scope. Each `ep.*`
  // function reads the active session via AsyncLocalStorage — no `auth`
  // binding required in Studio, no `<DataProvider name="ep">` wrap on
  // the client side.
  const prefetchedQueryData = await withEpSession(epCtx, () =>
    PLASMIC.unstable__getServerQueriesData(prefetchedData, {
      pageRoute: pageMeta.path,
      pagePath: plasmicPath,
      params: pageMeta.params ?? {},
      query: (await searchParams) ?? {},
    })
  );

  return (
    <PlasmicClientRootProvider
      prefetchedData={prefetchedData}
      prefetchedQueryData={prefetchedQueryData}
      pageParams={pageMeta.params}
    >
      <PlasmicComponent component={pageMeta.displayName} />
    </PlasmicClientRootProvider>
  );
}

4. Bind the queries in Studio

For each Server Query in the Plasmic UI, set the function and arguments. For a product detail page (/product/[slug]):

  • Function: ep.getProduct
  • Arguments: { id: $ctx.params.slug }

id takes a product reference — the product's slug, or its ID when it has none — so the same binding works on a store whose products carry slugs and one whose products do not. A UUID-shaped reference is read by ID first and by slug on a miss; anything else is read by slug. A reference with a character outside A-Z a-z 0-9 - _ . names no product and sends no request.

Then bind the EPProductProvider component's advanced product prop to $q.product.data. Server Queries appear under $q (not $queries) in the binding panel.

5. Resolve EP credentials from Studio config

clientId and host come from the EP Provider global context (configured in Studio), not from .env.local. createEpAuth's resolveConfig callback reads them with extractEpProviderConfig(prefetchedData, { hostAllowlist }), which scans the loader bundle for the global-context module, and the session carries them from then on; buildEpCtx reads them from the session. For projects without a homepage route, resolve a real page path via PLASMIC.fetchPages() rather than hardcoding / — see getEpProviderConfig in the example's lib/ep-auth.ts.

Common gotchas

| Symptom | Cause | Fix | |---|---|---| | $q.product.data always null / queries return null despite valid input | withEpSession not wrapped around unstable__getServerQueriesData | Wrap the query call per step 3; functions fail-soft to null outside an EP session scope | | prefetchedQueryData: "$undefined" in the SSR HTML | appDir: true not set in plasmic-init.ts | Add platformOptions: { nextjs: { appDir: true } } | | EP OAuth failed (401) Invalid credentials | resolveConfig found no EP Provider config (project has no homepage at /) | Ensure the storefront resolves a real page path via fetchPages() (already done if you copied lib/ep-auth.ts from the example) | | Studio binding still references auth: $ctx.ep | Project predates PRD #272 | Drop auth from each Server Query argument — the session now flows via ALS, not execParams |

Components

Core

  • EP Provider — Global context: clientId, host, locale, currency
  • Shopper Context — Global context: cartId, accountId, basePath overrides

Product Display

  • EPProductProvider — Single product data. Product ID or slug (productId) takes a product reference — usually $ctx.params.slug. A reference that names no product renders Empty Content, at runtime and on the canvas; a read that fails renders Error Content at runtime and the sample product on the canvas
  • EPProductListProvider — Paginated product listing. Products (pre-fetched) (initialPage, advanced) seeds the first page from an ep.getProductPage Server Query result, and that query's page[limit] overrides Page Size; paging discards the seed and falls back to client fetching. Offers no sort — Elastic Path's catalog product endpoints take no sort parameter, so use EPCatalogSearchProvider with EPSearchSortBy for a sortable listing
  • EPRelatedProductsProvider — Related products
  • EPProductGrid — Repeater for product list items

Cart

  • EPCartDrawer / EPCartInline — Cart display
  • EPCartDrawerTrigger — Opens cart drawer
  • EPCartItemList — Cart item repeater
  • EPCartItemField / EPCartItemImage — Cart item display
  • EPCartItemQuantityControl / EPCartItemQuantityButton — Quantity adjustment
  • EPCartItemRemoveButton — Remove item
  • EPCartField — Cart totals and metadata
  • EPAddToCartButton — Add to cart action

Checkout (Composable)

  • EPCheckoutProvider — Checkout state management
  • EPCheckoutStepIndicator — Multi-step progress
  • EPCustomerInfoFields / EPShippingAddressFields / EPBillingAddressFields — Form fields
  • EPShippingMethodSelector — Shipping options
  • EPPaymentElements — Payment form
  • EPCheckoutButton — Place order
  • EPOrderTotalsBreakdown — Order summary
  • EPCheckoutCartSummary / EPCheckoutCartItemList — Cart in checkout
  • EPPromoCodeInput — Promo codes
  • EPCountrySelect — Country picker

Checkout (Session-based)

  • EPCheckoutSessionProvider — Server-authoritative session
  • EPStripePayment / EPCloverPayment — Payment adapters

Accounts (Composable)

  • EPAccountProvider — Publishes $ctx.account from the shopper session: accountMember, selectedAccount, accountRoster ({ accounts, total }), lapsedAccount, derived state (anonymous | memberOnly | selected | lapsed), and isLoading (true until the session read settles; Gates render nothing while it is true). Tokens stay server-side; the Provider maps browser-readable identity only. Preview State is Studio only: explicit values force fixtures so Gates and Fields can be composed; auto uses live session identity when a member is present (otherwise the selected-account fixture). The published page always reads the live session and ignores Preview State
  • EPAccountGate — Renders children when $ctx.account matches a condition (authenticated, anonymous, selected, lapsed). Consumes the Provider boundary; does not fetch
  • EPAccountField — Displays one $ctx.account value (member id, selected/lapsed account name or id). Consumes the Provider boundary

Variations

  • EPVariationPicker / EPVariationOptionList / EPVariationOptionTrigger — Product variant selection
  • EPVariationField / EPVariationOptionField — Variant display

Bundles

  • EPBundleProvider — Bundle configuration
  • EPBundleComponentList / EPBundleOptionList — Bundle structure
  • EPBundleOptionTrigger / EPBundleOptionField — Option selection
  • EPBundleVariationPicker — Bundle variant selection
  • EPBundlePriceField / EPBundleValidationErrors — Bundle metadata

Stock / Inventory

  • EPStockProvider — Multi-location inventory
  • EPLocationPicker / EPLocationField — Location selection
  • EPStockField — Stock level display

Catalog Search

  • EPCatalogSearchProvider — Algolia InstantSearch wrapper
  • EPSearchBox / EPSearchHits / EPSearchPagination — Search UI
  • EPRefinementList / EPHierarchicalMenu / EPRangeFilter — Faceted filtering
  • EPSearchStats / EPSearchSortBy — Search metadata

Styling contract (catalog-search components)

The catalog-search components ship behaviour, not appearance. Every visual choice — typography, colour, spacing, borders, focus state — is the designer's. The components do not impose a default look that the designer later has to override.

The contract that every catalog-search component honours:

  1. className lands on the visible interactive element. The Plasmic style panel binds to a single class per component instance. We forward that class to the element designers actually want to style:

    | Component | Element that receives className | | --- | --- | | EPSearchBox | n/a — provider only, no DOM (see below) | | EPCatalogSearchProvider | the wrapper <div data-ep-catalog-search-provider> | | EPSearchHits | the grid <div data-ep-search-hits> | | EPRefinementList | the wrapper <div data-ep-refinement-list> | | EPHierarchicalMenu | the wrapper <div data-ep-hierarchical-menu> | | EPRangeFilter | the wrapper <div data-ep-range-filter> | | EPSearchPagination | the wrapper <div data-ep-search-pagination> | | EPSearchStats | the wrapper <div data-ep-search-stats> | | EPSearchSortBy | the wrapper <div data-ep-search-sort-by> |

  2. No inline style for appearance properties. The components never set border, border-radius, padding, font-*, color, background, or box-shadow inline. CSS specificity goes class-wins-over-element, so designer styles always reach the rendered DOM.

  3. Inline style is permitted only for layout properties Plasmic strips. Plasmic's canvas filters out display/grid-*/flex-* styles set on a code-component instance. EPSearchHits works around this by setting its grid layout inline, and exposing the values as gridTemplateColumns / gridGap props so designers can still control them.

  4. The editor and the runtime render the same DOM. No mock-only inline styling that lies about runtime output — what the designer sees in the Plasmic canvas is what the live site renders. Test coverage in __tests__/catalog-search-components.test.tsx enforces this.

Structural CSS via :where()

The components do need a small amount of structural CSS — for example, position: relative on the EPSearchBox wrapper so its absolute-positioned clear button can anchor. We ship that CSS once per page from headless-styling.ts, scoped via :where() so every selector has zero specificity and any designer class always wins.

If you add a new catalog-search component, follow the same pattern:

import { useHeadlessStyling } from "./headless-styling";

export function EPNewSearchComponent({ className, ...props }) {
  useHeadlessStyling();
  return <div className={className} data-ep-new-search-component="">…</div>;
}

Then add a contract test alongside the existing nine in __tests__/catalog-search-components.test.tsx via describeHeadlessStylingContract. The helper asserts (a) the className lands on the documented leaf, (b) no inline appearance styles are set anywhere in the rendered tree, and (c) the editor and runtime renders produce the same root tag.

Composing EPSearchBox

EPSearchBox is a provider, not a renderer. It exposes search-field state to its slot children and otherwise renders nothing. The visible chrome — the <input>, the clear <button> — is owned by the designer. Drop a Plasmic-controlled input and button into the slot and wire them via $ctx.searchFieldData and the registered ref-actions.

| What EPSearchBox exposes | Type | Use it for | | --- | --- | --- | | $ctx.searchFieldData.value | string | the controlled value of the input element | | $ctx.searchFieldData.displayValue | string | the query that has actually been refined (diverges from value during the debounce window) | | $ctx.searchFieldData.isEmpty | boolean | hide the clear button when nothing is typed | | setValue(value: string) ref-action | | call from the input's onChange interaction | | clear() ref-action | | call from the clear button's onClick interaction |

Wiring a fresh EPSearchBox in Plasmic Studio:

  1. Drop an <input> (Plasmic's built-in tag, not a code component) into the EPSearchBox slot. Style it freely from the style panel — appearance reaches the live site because the input is a Plasmic-controlled tag.
  2. Set the input's value attribute to a dynamic value: $ctx.searchFieldData.value.
  3. Add an onChange interaction → custom function: EP_SEARCH_BOX_REF.setValue(event.target.value), where EP_SEARCH_BOX_REF is the ref to the parent EPSearchBox instance.
  4. Drop a <button> (also a Plasmic-controlled tag) for clear. Style it freely.
  5. Add the button's onClick → custom function: EP_SEARCH_BOX_REF.clear().
  6. Bind the button's visibility to !$ctx.searchFieldData.isEmpty so it only shows when there's something to clear.

Why this composition: Plasmic's codegen filters appearance styles (padding, border, background, font-*, color, border-radius, box-shadow) off any code-component instance — only the Plasmic-controlled tags (input, button, div, etc.) escape the filter. By making EPSearchBox a render-children-only provider, the visible chrome is owned by tags the designer fully controls. See PRD #308 for the long-form rationale.

Composing EPSearchPagination

EPSearchPagination follows the same provider pattern. The default slot ships an hbox with Prev button, page-indicator text, and Next button so a fresh drop renders working chrome shape — the designer wires the behaviour.

| What EPSearchPagination exposes | Type | Use it for | | --- | --- | --- | | $ctx.searchPaginationData.currentPage | number | zero-indexed current page | | $ctx.searchPaginationData.totalPages | number | total page count | | $ctx.searchPaginationData.hasPrev | boolean | hide Prev button when false | | $ctx.searchPaginationData.hasNext | boolean | hide Next button when false | | $ctx.searchPaginationData.pages | number[] | page-number buttons array | | goToPage(page: number) ref-action | | call from a page-number button's onClick | | prevPage() ref-action | | call from the Prev button's onClick | | nextPage() ref-action | | call from the Next button's onClick |

Wiring a fresh EPSearchPagination in Plasmic Studio:

  1. The default slot already contains a Prev button, a page text, and a Next button — restyle them freely from the style panel.
  2. Replace the page-text content with a dynamic expression, e.g. `Page ${$ctx.searchPaginationData.currentPage + 1} of ${$ctx.searchPaginationData.totalPages}`.
  3. Wire the Prev button's onClick → invoke ref-action prevPage on the EPSearchPagination instance. Bind its visibility to $ctx.searchPaginationData.hasPrev.
  4. Wire the Next button's onClick → invoke ref-action nextPage. Bind its visibility to $ctx.searchPaginationData.hasNext.

Composing EPSearchSortBy

EPSearchSortBy exposes the active sort and a setSort ref-action. The recommended slot pattern is a native <select>:

| What EPSearchSortBy exposes | Type | Use it for | | --- | --- | --- | | $ctx.sortByData.currentValue | string | bind to the <select>'s value attribute | | $ctx.sortByData.options | Array<{value, label}> | normalised options as useSortBy sees them | | setSort(value: string) ref-action | | call from the <select>'s onChange |

The items prop accepts two shapes:

  1. Ergonomic (recommended) — { field, direction, label }:

    [
      { label: "Most Relevant" },                                                   // default/unsorted
      { field: "price.USD.float_price", direction: "asc",  label: "Price: Low to High" },
      { field: "price.USD.float_price", direction: "desc", label: "Price: High to Low" },
      { field: "name", direction: "asc", label: "Name: A to Z" },
    ]

    Components compose ${indexName}/sort/${field}:${direction} internally — the format the EP catalog-search-instantsearch-adapter parses. Field names are Typesense field paths on the EP catalog index (name, sku, price.<currency>.float_price, created_at, etc.).

  2. Raw (escape hatch) — { value, label } where value is the full indexName like "search/sort/foo:asc". Use when you need to bypass the composer.

If the parent EPCatalogSearchProvider uses a non-default indexName, set the matching value on the indexName prop here too — otherwise the composed sort URLs target the wrong index.

Wiring a fresh EPSearchSortBy in Plasmic Studio:

  1. Drop a <select> (Plasmic-controlled tag) into the slot.
  2. Bind its value attribute to $ctx.sortByData?.currentValue.
  3. Add <option> children for each sort entry — the option's value attribute should match the composed value from $ctx.sortByData.options (i.e. $ctx.sortByData.options[N].value), or hardcode if you know the indexName.
  4. Wire the <select>'s onChange interaction → invoke ref-action setSort on the EPSearchSortBy instance with arg event.target.value.

Migration notes

Before this contract was in place, EPSearchBox and EPCatalogSearchProvider shipped polished default styles inline. Designers who relied on those defaults will need to re-style the component from the Plasmic style panel after upgrading. The defaults were misleading — they appeared in the canvas preview but only some applied at runtime — so a clean reset is healthier than continuing to ship the lie.

PRD #308 also moved EPSearchBox from a chrome-rendering component to a provider. Existing EPSearchBox instances will lose their input and clear button; re-author the slot per the wiring steps above.