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

@littlestall/sdk

v0.2.0

Published

TypeScript client for the Littlestall Headless API — read a store's catalog from any JavaScript runtime.

Downloads

198

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/sdk

Quick 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