@usestorekit/sdk
v0.8.1
Published
Type-safe, ergonomic SDK for the StoreKit Storefront API
Maintainers
Readme
@usestorekit/sdk
Type-safe, ergonomic SDK for the StoreKit Storefront API. Namespaced calls, a
{ data, error } result on every method (never throws by default), fully typed
requests/responses — and a Next.js adapter that wires the whole storefront
(cookie session, cart, catalog caching, a same-origin proxy, and React hooks) in
about two minutes.
Browser ──► your Next.js app (proxy + cache) ──► StoreKit Storefront APIYour store key never reaches the browser: the browser calls a catch-all route on your own app, which injects the key and the customer's session cookie server-side.
Install
npm install @usestorekit/sdk zod
# or: pnpm add @usestorekit/sdk zodreact and next are optional peers — needed only for the /react and /next
entries.
Next.js quickstart (≈2 min)
1. Environment
# .env.local
STOREFRONT_STORE_KEY=sk_live_xxx # server-only — never NEXT_PUBLIC_*
STOREFRONT_URL=https://shop.your-domain.com # your storefront's public base URL —
# the SDK's baseURL (post-payment return)
STOREFRONT_API_URL=https://api.your-storekit.com # optional — defaults to
# https://api.storekit.app (production).
# Set only for self-hosted/staging.Custom (self-hosted) storefronts must set
STOREFRONT_URLso the post-payment redirect lands on your domain instead of the default*.storekit.appsubdomain. See Payments & the return route.
2. The server instance — lib/storekit.ts
import { initStorekit } from "@usestorekit/sdk/next";
export const storekit = initStorekit(); // reads env, wires cookies + cache3. The catch-all proxy — app/api/storefront/[...path]/route.ts
import { storekit } from "@/lib/storekit";
export const { GET, POST, PUT, PATCH, DELETE } = storekit.handler();4. The browser client — lib/storekit-client.ts
"use client";
import { createStorekitClient } from "@usestorekit/sdk/react";
export const storefront = createStorekitClient(); // talks to /api/storefront5. The payment return route — app/payment/callback/page.tsx
Checkout is a redirect flow: after paying, the customer is returned to
/payment/callback on your storefront. This route is required — skip it and a paid
order 404s. The usePaymentConfirmation hook handles reconciliation; see
Payments & the return route for the page.
That's the entire integration. Now use it:
// Server Component — reads the API directly, public catalog reads auto-cached
import { storekit } from "@/lib/storekit";
const { data } = await storekit.products.list({ limit: 20 });// Server Action — cart + session cookies handled for you (no cart id juggling)
"use server";
import { storekit } from "@/lib/storekit";
export async function addToCart(variantId: string) {
await storekit.cart.add({ variantId, quantity: 1 });
}
export async function login(phone: string, otp: string) {
// claims the guest cart, stores the token in the httpOnly session cookie
return storekit.auth.verifyOtp(phone, otp);
}// Client Component — shared-state hooks, proxied through your catch-all route
"use client";
import { storefront } from "@/lib/storekit-client";
export function CartBadge() {
const { count } = storefront.useCart();
return <span>Cart ({count})</span>;
}The two surfaces
| | Function | Import | Talks to | Use in |
| --- | --- | --- | --- | --- |
| Server | initStorekit() | @usestorekit/sdk/next | StoreKit API directly | RSC, generateMetadata, Server Actions — and it backs the catch-all route |
| Browser | createStorekitClient() | @usestorekit/sdk/react | your same-origin /api/storefront/* | Client Components |
initStorekit(config?) → storekit
storekit.products / categories / pages / store / search / coupons / checkout / customer / payment
// direct, typed, public reads auto-cached
storekit.cart // cookie-stateful: current() · add(item) · setQuantity(lineId, n)
// · remove(lineId) · clear() · count()
storekit.auth // cookie-stateful: requestOtp(phone) · verifyOtp(phone, otp)
// · logout() · session()
storekit.handler() // the catch-all Route Handlers
storekit.$client // escape hatch: the framework-agnostic clientconfig (all optional): apiURL, storeKey, outletId, baseURL (your storefront's
public URL — where customers return after payment), revalidate (catalog cache window,
default 300s), cookies (session / cart names), fetch. Defaults come from
STOREFRONT_API_URL / STOREFRONT_STORE_KEY / STOREFRONT_OUTLET_ID / STOREFRONT_URL.
apiURL falls back to https://api.storekit.app (production) when unset, so only
self-hosted/staging integrations need to set it.
Cookie writes (
cart.add,auth.verifyOtp, …) require a Server Action or Route Handler — Next can't set cookies during render. Reads (cart.current,customer.get) work anywhere.
createStorekitClient(config?) → storefront
storefront.cart // get() · add(item) · setQuantity(lineId, n) · remove(lineId) · clear()
storefront.auth // requestOtp(phone) · verifyOtp(phone, otp) · logout() · session()
storefront.products / categories / pages / store / search / coupons / checkout / customer / payment
storefront.useCart() // { cart, count, loading, error, add, setQuantity, remove, clear, refresh }
storefront.useStore() // { data, error, loading }
storefront.useSession() // { data: customer | null, error, loading, refresh, update(input) }
storefront.useAddresses()// { addresses, loading, error, refresh, create, update, remove }
storefront.usePaymentConfirmation(orderId) // { status, order, error, message, retry } — for /payment/callbackuseCart() shares one cart state across every component using the same client, so a
header badge updates the instant another component adds an item — no polling. Config:
basePath (default /api/storefront), fetch, headers.
Mutations are optimistic. useCart (add / setQuantity / remove / clear),
useSession().update, and useAddresses (create / update / remove) apply
locally right away, then reconcile with the server and roll back on error. The cart
subtotal, line totals, and count update instantly; the grand total and charges
(tax, packaging, delivery) stay server-authoritative and settle on reconcile — they can
depend on thresholds the browser can't reproduce. Rapid taps are safe: a slow in-flight
response can't overwrite newer state.
First adds are optimistic too — automatically. To render a brand-new line the SDK
needs the variant's display data (name / price / image), which it can't derive from a
variant id alone. It harvests that data from every catalog read it already proxies
(products.list / products.get / search) into a small in-memory index, so a first
add({ variantId, quantity }) synthesizes its line instantly with no extra arguments —
as long as the variant was loaded through the client.
For the cold case — a server-rendered product page where the browser never fetched the
catalog itself — pass an optimistic hint to guarantee a synchronous first add. Build it
from the product + variant you already have with lineHint:
import { lineHint } from "@usestorekit/sdk/react";
const { add } = storefront.useCart();
await add({ variantId: variant.id, quantity: 1 }, lineHint(product, variant));
// pass selected modifiers so their prices are folded into the optimistic unit price:
await add({ variantId, quantity: 1, modifiers }, lineHint(product, variant, { modifiers: selected }));Without a hint and with a cold index (or before the cart has loaded), the add falls
back to a count-only bump and the row arrives on reconcile.
What the proxy does
storekit.handler() mounts three things at your route:
- Cart facade (
/cart,/cart/items[/:lineId]) — cookie-stateful, so the browser never sees or sends a cart id. - Auth facade (
/auth/otp/*,/auth/logout,/auth/session) — sets/clears the httpOnly session cookie; the bearer token is never echoed to the browser. - Transparent
/v1/*proxy — injects the store key + session token, caches public catalog reads, and forwards everything else withno-store. Only/v1/*is forwardable; path traversal is rejected.
Payments (PhonePe) & the return route
Checkout is a redirect flow, and it needs one route on your storefront that the quickstart above doesn't create. Here's the round-trip:
checkout.create() ──► PhonePe hosted page ──► StoreKit API ──► YOUR /payment/callback
(you navigate (customer pays) (resolves the (confirm + show result)
to redirectUrl) store + status)checkout.create()returns a PhonePeredirectUrl; navigate the browser to it.- After payment, PhonePe returns to the StoreKit API, which 302s the customer to
<your-storefront>/payment/callback?orderId=…&status=…. - That page calls
checkout.confirm(orderId)to reconcile (the webhook may have already settled the order) and renders success / pending / failure.
The /payment/callback path is required — without it, a successfully paid order
lands on a 404. The usePaymentConfirmation hook does the confirm + retry + status
machine; you supply the markup:
// app/payment/callback/page.tsx
"use client";
import { Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { storefront } from "@/lib/storekit-client";
import { formatMoney } from "@usestorekit/sdk/react";
function Result() {
const orderId = useSearchParams().get("orderId");
const { status, order, message, retry } = storefront.usePaymentConfirmation(orderId);
if (status === "processing") return <p>Confirming your payment…</p>;
if (status === "success")
return <p>Order placed! Total {order && formatMoney(order.total, order.currency)}.</p>;
if (status === "pending")
return (
<div>
<p>{message ?? "Payment is still processing."}</p>
<button onClick={retry}>Check again</button>
</div>
);
return (
<div>
<p>{message ?? "Payment could not be confirmed."}</p>
<button onClick={retry}>Retry</button>
</div>
);
}
// useSearchParams must be inside <Suspense>
export default function Page() {
return (
<Suspense>
<Result />
</Suspense>
);
}Where customers return — baseURL
Tell the SDK your storefront's public base URL, once — the API derives the post-payment
redirect (<baseURL>/payment/callback) from it. For integrated storefronts (the
default *.storekit.app subdomain) it just works without this. For a custom storefront
on your own domain, set it so the redirect comes back to you:
// lib/storekit.ts
export const storekit = initStorekit({
baseURL: process.env.STOREFRONT_URL, // e.g. https://shop.acme.com (this is the default)
});The SDK injects it into every checkout server-side (the browser can't override it),
the API persists it on the order, and the redirect uses it. Resolution precedence on the
API: the order's baseURL → the store's dashboard-configured URL → the store's registered
domains. If you don't set it, the SDK warns in the console — explicit is better than a
surprise redirect to the wrong origin.
Orders
customer.orders.list({ cursor?, limit? }) returns paginated history (keyset cursor,
plus lifetime totalSpent); customer.orders.get(orderId) returns one order. Both
carry the full charge breakdown and shipping address, so an order page can itemise totals
instead of deriving a remainder:
const { data } = await storefront.customer.orders.get(orderId);
// data.orderNumber — per-store human number (e.g. 1042)
// data.subtotal, data.taxAmount, data.deliveryCharge, data.packagingCharge,
// data.discountAmount, data.total — all numbers in data.currency
// data.deliveryDistanceKm — number | null
// data.shippingAddress / data.billingAddress
// — { firstName, lastName, address, city,
// state, zipCode, country, latitude?,
// longitude? } | null. billingAddress
// defaults to shipping at checkout.The single-order endpoint also returns payment, refunds, a status timeline, shipments, and a receipt link (the list omits these to stay lean):
data.payment; // { transactionId, providerRef, amount, paidAt,
// method, last4, upiId, reference } | null — null until paid.
// Instrument fields are best-effort (any may be null).
data.refunds; // Array<{ id, amount, reason, createdAt }>
data.statusEvents; // Array<{ fromStatus, toStatus, action, createdAt }> — oldest→newest
data.shipments; // Array<{ awbCode, carrier, trackingUrl, status, items, … }> (partial-shipment aware)
data.invoiceUrl; // signed, time-limited link to a printable HTML receiptTracking fields (deliveryStatus, deliveryPartner, trackingUrl, estimatedDelivery,
deliveredAt, estimatedReadyAt) and notes come through on both. Types: Order,
OrderItem, OrderShippingAddress, OrderPayment, OrderRefund, OrderStatusEvent,
OrderShipment.
Pass a separate
billingaddress tocheckout.create()when it differs from shipping. Still not modelled: order-leveltaxInclusive(the cart exposes it; the order doesn't yet).
The { data, error } contract
Every method resolves to a discriminated result — it never throws (unless you set
throwOnError):
const { data, error } = await storekit.products.get("slug");
if (error) {
// data is null; error is a StorefrontError ({ message, code, status, retryAfter?, details? })
} else {
// data is the Product
}import { isUnauthorized, isRateLimited } from "@usestorekit/sdk/next";
const { error } = await storekit.cart.current();
if (error && isUnauthorized(error)) redirect("/login");code mirrors the API envelope (UNAUTHORIZED, RATE_LIMITED, NOT_FOUND,
route-specific codes like INSUFFICIENT_STOCK, plus NETWORK / TIMEOUT). Guards:
isStorefrontError, isRateLimited, isUnauthorized, isNotFound.
Helpers & types
import { formatMoney } from "@usestorekit/sdk"; // locale pinned → no hydration mismatch
import type { Product, Cart, Order, Customer, Address } from "@usestorekit/sdk/next";formatMoney(amount, currency?, locale?) is exported from all three entries.
Advanced: the framework-agnostic client
@usestorekit/sdk/next is built on a portable core client you can use directly (other
frameworks, edge functions, scripts). It takes an explicit baseURL + storeKey and a
pluggable SessionStore:
import { createStorefrontClient } from "@usestorekit/sdk";
const sf = createStorefrontClient({
baseURL: process.env.STOREFRONT_API_URL!,
storeKey: process.env.STOREFRONT_STORE_KEY!,
// session, outletId, fetch, credentials, throwOnError, headers, onRequest/onResponse/onError
});
const { data, error } = await sf.products.list({ limit: 20 });Cart helpers here are explicit about the cart id and operate on lines
(cart.items[].id), so variants that differ only by modifiers stay independent:
await sf.cart.create({ items: [{ variantId, quantity: 2 }] });
const { data: cart } = await sf.cart.me();
await sf.cart.setQuantity(cart!.id, cart!.items[0].id, 3);
await sf.cart.addItem(cart!.id, { variantId, quantity: 1 }); // merges same variant + modifierscreateStorefrontClient(config) options: baseURL (required), storeKey, outletId,
session (defaults to memorySession()), fetch, credentials, throwOnError,
headers, onRequest / onResponse / onError. Per-request options (2nd arg):
cache, revalidate, tags, signal, headers, outletId.
Sessions are per-request.
memorySession()keeps one token for the client's lifetime — fine for a single user, but don't share a long-lived client across users. On the server use a request-scopedSessionStore(the/nextadapter does this for you viacookieSession()).
Resource reference (both clients)
All methods take an optional final RequestOptions.
auth — requestOtp · verifyOtp · logout · getToken
products — list({ limit?, cursor?, categorySlug?, tagSlug?, status? }) · get(slug) · listAll(query?)
cart (core) — create · get · me · update · delete · claim · addItem · setQuantity · removeItem
checkout — create({ orderType?, shipping?, billing?, couponCode?, notes?, scheduledFor?, idempotencyKey? }) · confirm(orderId)
customer — get · update · orders.list · orders.get · addresses.list/create/update/delete
payment — createIntent(orderId) · status(orderId) · checkoutUrl(transactionId) — retry/resume + poll an order's payment
store — get · outlets · categories — list · get(slug, query?) · pages — list · get(slug) · search — search({ q, … }) · coupons — validate({ code })
Escape hatches
storekit.$client; // the underlying core client (server)
storefront.$core; // the underlying core client (browser)
client.$transport.request<T>("GET", "/v1/anything", { query, options }); // raw typed call