@littlestall/sdk
v0.2.0
Published
TypeScript client for the Littlestall Headless API — read a store's catalog from any JavaScript runtime.
Downloads
198
Maintainers
Readme
@littlestall/sdk
The official TypeScript client for the Littlestall Headless API — the API a storefront reads its catalog from, and signs its shoppers in with.
One client is bound to one store, so the store slug is something you give once rather than something every call has to carry.
npm install @littlestall/sdkQuick start
import { createHeadlessClient } from "@littlestall/sdk";
const client = createHeadlessClient({ store: "acme" });
const store = await client.store.get();
const { items, total } = await client.products.list({ limit: 20 });
const tee = await client.products.get("classic-tee");
console.log(
`${tee.title} — ${tee.product_variants[0].price} ${store.currency_code}`
);There is no API key: the catalog is public, which is why the client is safe to run in a shopper's browser as well as on a server. Signing in is the one thing that carries a credential, and it is the shopper's own — a token the client gets when they type in the code that was mailed to them.
Works anywhere there is fetch — Node 20+, Bun, Deno, Cloudflare Workers,
Vercel Edge, and the browser. Ships ESM and CJS, with types, and no
dependencies.
Configuration
const client = createHeadlessClient({
store: "acme", // required — the store to read
baseUrl: "http://localhost:8000", // optional — defaults to Littlestall's API
fetch: myFetch, // optional — defaults to the runtime's own
headers: { "x-source": "storefront" }, // optional — sent on every request
session: myStorage, // optional — where a signed-in shopper's token is kept
});baseUrl takes the API root, and the headless mount point is added for
you: http://localhost:8000 reads from http://localhost:8000/headless/v1,
which is what you want when developing against a local API. A URL that already
has a path of its own is used exactly as given, so a storefront proxying the
API at https://shop.example.com/api keeps its own shape.
Every method also takes per-call request options — an AbortSignal, extra
headers, caching hints — passed straight to fetch:
await client.products.list({ limit: 20 }, { signal: controller.signal });
// Next.js: `next` is passed through untouched.
await client.products.get("classic-tee", { next: { revalidate: 60 } });Reading the catalog
client.store.get()
The store itself: its name, its slug, and the ISO 4217 currency_code every
price in the catalog is denominated in.
const store = await client.store.get();
// { id, name: "Acme", slug: "acme", currency_code: "USD" }client.products.list(params?)
One page of the store's listed products, newest first. Up to 100 per call;
total says how many there are in all.
const page = await client.products.list({ limit: 20, offset: 0 });
// { items: Product[], total: 84, limit: 20, offset: 0 }client.products.listAll(params?)
Every listed product, a page at a time, as an async iterable — for the build step that renders a page per product.
for await (const product of client.products.listAll()) {
await render(product);
}client.products.get(slug)
One product by the slug your storefront puts in its own URLs. Throws
NotFoundError when the store has no such product.
const product = await client.products.get("classic-tee");client.products.find(slug)
The same, answering null instead of throwing — the shape a not-found page
usually wants.
const product = await client.products.find(params.slug);
if (!product) {
return notFound();
}client.health()
Whether the API is serving.
client.forStore(slug)
The same client pointed at another store — same API, same fetch, same
headers. For server code that renders more than one merchant's storefront. Not
the same session: a token names the store it was minted for and is refused at
any other, so the new client starts with nobody signed in.
const acme = createHeadlessClient({ store: "acme" });
const globex = acme.forStore("globex");Navigation
The merchant builds their shop's menus in the Littlestall console — a header menu, a footer menu, whatever else they name — and this is how you read them. Each menu is a tree: items, the items under them, and one more level under those. Three levels is the most the API stores.
client.menus.list()
Every menu the store has, each with its items already nested.
Not paged, and deliberately: a store's menus are a handful of labels, so one call is enough to build a header and a footer together.
const menus = await client.menus.list();
const header = menus.find((menu) => menu.slug === "main-menu");client.menus.get(slug)
One menu by its slug — main-menu, footer-menu, or whatever the
merchant named theirs. Throws NotFoundError when there is no such menu.
Name the slug in your own source rather than an id. The slug is what the merchant sees in the console, and it stays put while they rename the menu's title or replace everything in it.
const header = await client.menus.get("main-menu");client.menus.find(slug)
The same, answering null instead of throwing. Usually the one you want for
chrome: a shop whose merchant has not made a main-menu yet should render your
own fallback nav, not fail the page.
const header = await client.menus.find("main-menu");
return header ? <Nav menu={header} /> : <DefaultNav />;What a menu looks like
{
id: "…",
title: "Main menu",
slug: "main-menu",
menu_items: [
{
id: "…",
label: "Home",
position: 1,
link_type: "url",
url: "/",
slug: null,
children: [],
},
{
id: "…",
label: "Winter",
position: 2,
link_type: "collection",
url: "/collections/winter",
slug: "winter",
children: [ /* same shape, up to two levels deeper */ ],
},
],
}link_type is "url", "product" or "collection" — whether the merchant
typed an address or picked something out of their catalog.
url is where the item goes, worked out for you. For a product or a collection
it is resolved from the target; for a typed address it is what the merchant
typed, which may be relative (/about) or absolute
(https://example.com).
slug is the slug of the product or collection the item names, and null
when it names neither. It is what lets you route to your own pages — see below.
Note the two slugs do different jobs: the menu's is its own stable name
(main-menu), and an item's is the name of whatever that item points at.
Rendering a menu
If your product pages live at /products/<slug> and your collection pages at
/collections/<slug>, url is already right and there is nothing to work out:
function Nav({ items }: { items: MenuItem[] }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>
<a href={item.url}>{item.label}</a>
{item.children.length > 0 && <Nav items={item.children} />}
</li>
))}
</ul>
);
}Routing to your own pages
Your storefront is your own, and its routes are yours. If your products live
somewhere else, build the path from link_type and slug rather than taking
url apart — url is this platform's convention, and parsing it would tie your
shop to a format that is not yours:
function href(item: MenuItem): string {
switch (item.link_type) {
case "product":
return `/shop/${item.slug}`;
case "collection":
return `/c/${item.slug}`;
default:
// A typed address. Relative ones are yours; absolute ones leave the shop.
return item.url;
}
}An item's slug is also the argument products.get() and collections.get()
take, so a menu is enough to prefetch what it points at:
const featured = await Promise.all(
menu.menu_items
.filter((item) => item.link_type === "product")
.map((item) => client.products.get(item.slug!))
);Two things to know
An item whose target has since been deleted is left out of the menu entirely, along with anything under it. You will never be handed a menu item that goes nowhere, so there is no dead-link case to write.
A typed address may point off your site. Treat anything with a scheme
(https:, mailto:, tel:) or starting // as external — render it as a
plain anchor with rel="noopener noreferrer" rather than handing it to your
router:
const isExternal = (url: string) => /^[a-z][a-z0-9+.-]*:|^\/\//i.test(url);Signing shoppers in
There is no password. A shopper types their email, gets a six-character code in their inbox, and types it back — which is the same two calls whether they have shopped here before or not.
await client.auth.requestOtp({ email: "[email protected]" });
// …the shopper reads the code from their inbox…
const { customer } = await client.auth.verifyOtp("[email protected]", "BD6L37");The client keeps the token it gets back and sends it as
Authorization: Bearer … on every later call, so there is nothing to thread
through your own code. Tokens are good for 30 days.
client.auth.requestOtp(payload)
Mails a code to an address. The address is the whole of it — the same call signs a shopper in and signs them up:
// Says nothing about whether that address has an account here.
await client.auth.requestOtp({ email: "[email protected]" });Codes are limited to fifteen an hour per address, which answers 429.
client.auth.verifyOtp(email, code)
Redeems a code and opens a session. A code works once, expires five minutes after it was asked for, and is spent after three wrong tries. An address with no account here gets one, built from the address alone.
const { customer, expires_in } = await client.auth.verifyOtp(email, code);A wrong code throws UnauthorizedError (401); an expired, used or spent one
throws LittlestallApiError with status 400.
client.customers.self()
The shopper this client's session belongs to. This is what a storefront calls on load to find out whether the token it is holding is still good:
import { isUnauthorizedError } from "@littlestall/sdk";
const customer = await client.customers.self().catch((error: unknown) => {
if (isUnauthorizedError(error)) {
return client.auth.signOut().then(() => null);
}
throw error;
});client.auth.token() and client.auth.signOut()
token() answers the token in hand, or null when nobody is signed in.
signOut() forgets it — the API keeps no session, so that is all of it.
Where the token is kept
In a browser, localStorage, under littlestall.session.<store-slug>, so a
shopper stays signed in across reloads and tabs. Anywhere else, in memory, for
as long as the client object lives.
localStorage is readable by script running on the page, so a storefront with
a cross-site scripting hole has a session-stealing hole too. It is still where
this goes: the API is on a different origin from your storefront, so there is
no cookie of ours a browser would attach on its own, and the alternative is a
shopper signed out by every reload.
On a server, make a client per request. A single shared client holds one
shopper's token in memory and would hand it to whoever asked next. Give each
request its own client, or pass a session that reads that request's own
cookie:
import {
createHeadlessClient,
createMemorySessionStorage,
type SessionStorage,
} from "@littlestall/sdk";
const sessionFrom = (request: Request): SessionStorage => ({
get: () => readTokenCookie(request),
set: (token) => queueSetCookie(token),
clear: () => queueClearCookie(),
});
export const handler = (request: Request) =>
createHeadlessClient({ store: "acme", session: sessionFrom(request) });Every method may be async, which is what lets a native app back this with its
own keychain. createMemorySessionStorage() is exported for the times you want
a session that lives exactly as long as one object.
What a product looks like
A product arrives whole — no second call to fill it in:
type Product = {
id: string;
title: string;
slug: string;
description: string | null;
status: "active" | "unlisted";
created_at: string;
updated_at: string | null;
product_media: { position: number; media: Media }[];
product_options: ProductOption[]; // "Size", "Colour", and their values
product_variants: ProductVariant[]; // every combination those options make
};Each variant carries its own price and stock, plus is_available — the
question a storefront actually asks:
type ProductVariant = {
id: string;
title: string;
sku: string | null;
price: string; // "24.99"
compare_at_price: string | null;
track_inventory: boolean;
inventory_quantity: number;
is_available: boolean;
product_variant_options: { product_option_value: ProductOptionValue }[];
};Prices are strings, not numbers. "24.99" is exact; 24.99 as a float is
not, and money that round-trips through one drifts. Format them with Intl, or
do arithmetic in minor units or with a decimal library:
new Intl.NumberFormat("en-US", {
style: "currency",
currency: store.currency_code,
}).format(Number(variant.price));A product is active or unlisted. Listed products come back from
products.list(); an unlisted one resolves by slug alone, so a link the
merchant hands out works while nothing leads to it on its own. Drafts are never
served.
Every type is exported — Store, Product, ProductVariant, ProductOption,
ProductOptionValue, ProductMedia, Media, ProductPage, Page<T> — along
with the generated response types they alias (ProductResponse, …), so code
generated from the same OpenAPI document elsewhere meets this without a cast.
Errors
Any non-2xx answer throws LittlestallApiError, carrying the API's own
message, its machine-readable code, the HTTP status, and the parsed data.
Two get a subclass of their own, because they are the failures a storefront
routinely handles: NotFoundError for a 404, and UnauthorizedError for a
401 — no session, or one that is no longer good.
import { isNotFoundError, LittlestallApiError } from "@littlestall/sdk";
try {
const product = await client.products.get(slug);
} catch (error) {
if (isNotFoundError(error)) {
return renderNotFound();
}
if (error instanceof LittlestallApiError) {
console.error(error.status, error.code, error.message);
}
throw error;
}A request that never got an answer at all — DNS, a dropped connection, a CORS
refusal — surfaces as the runtime's own fetch TypeError.
Examples
Next.js — a product page, revalidated hourly
// app/products/[slug]/page.tsx
import { createHeadlessClient } from "@littlestall/sdk";
import { notFound } from "next/navigation";
const client = createHeadlessClient({ store: process.env.STORE_SLUG! });
export async function generateStaticParams() {
const slugs = [];
for await (const product of client.products.listAll()) {
slugs.push({ slug: product.slug });
}
return slugs;
}
export default async function ProductPage({ params }) {
const { slug } = await params;
const product = await client.products.find(slug, {
next: { revalidate: 3600 },
});
if (!product) {
notFound();
}
return <ProductView product={product} />;
}A gallery in display order
const images = product.product_media
.filter(({ media }) => media.type === "image")
.sort((a, b) => a.position - b.position)
.map(({ media }) => ({ src: media.url, alt: media.alt ?? product.title }));The variant a shopper has chosen
const chosen = { Size: "M", Colour: "Black" };
const variant = product.product_variants.find((variant) =>
variant.product_variant_options.every(({ product_option_value }) => {
const option = product.product_options.find(
(option) => option.id === product_option_value.product_option_id
);
return option && chosen[option.name] === product_option_value.value;
})
);Versioning
The package follows semver. The API surface it reads is versioned separately
and in its path (/headless/v1): a breaking change to the API arrives as a v2
beside v1, and v1 keeps answering unchanged for the storefronts already built.
Reference
The API this client reads is documented at https://api.littlestall.com/headless/v1/docs, and its OpenAPI document is at https://api.littlestall.com/headless/v1/openapi.json.
License
MIT
