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 🙏

© 2025 – Pkg Stats / Ryan Hefner

better-shop

v1.2.0

Published

A pluggable, type-safe shop service built with better-call and shop-client

Downloads

434

Readme

Better Shop

A pluggable, type-safe shop service built with better-call and shop-client. It provides a full-featured RPC API for interacting with Shopify stores, including product fetching, collection management, checkout URL generation, and LLM-powered enrichment.

Features

  • Type-Safe RPC: End-to-end type safety with better-call and arktype.
  • Full Shopify Integration: Covers products, collections, store info, and checkout.
  • Caching: Built-in caching for store info and other resources.
  • Pluggable: Easy to mount into any existing better-call router or server (Bun, Node, etc.).
  • LLM Ready: Includes endpoints for product classification, SEO generation, and enrichment.

Installation

npm install better-shop
# or
bun add better-shop

Usage

Server

Create a shop router and serve it (e.g., using Bun). The router expects an x-shop-domain header in every request to identify the target store.

import { betterShop } from "better-shop";

const router = betterShop({
    cacheTTL: 60_000 // Optional: Cache TTL in ms
});

Bun.serve({
    fetch: router.handler
});

Client

Use the better-call client for type-safe interaction. Ensure you pass the x-shop-domain header.

import { createClient } from "better-call/client";
import type { betterShop } from "better-shop";

// Create a type alias for the router return type
type ShopRouter = ReturnType<typeof betterShop>;

const client = createClient<ShopRouter>({
    baseURL: "http://localhost:3000",
    headers: {
        "x-shop-domain": "your-shop.myshopify.com"
    }
});

// Fetch products
const products = await client("/products/paginated", {
    query: { page: 1, limit: 10 }
});

if (products.data) {
    console.log(products.data);
}

Composable Endpoints

You can compose only the endpoints you need using named exports. This is useful when you want a smaller router or custom OpenAPI settings.

Named Exports

  • makeGetShop – creates a getShop(headers) function using ShopClient
  • buildStoreEndpoints – returns { getInfo, clearInfoCache, determineStoreType }
  • buildProductEndpoints – returns product endpoints like { getAllProducts, getPaginatedProducts, getShowcasedProducts, getProductFilters, getProduct, getEnrichedProduct, classifyProduct, generateProductSEO }
  • buildCollectionEndpoints – returns collection endpoints like { getAllCollections, getPaginatedCollections, getShowcasedCollections, getCollection, getCollectionProductsAll, getCollectionProductsPaginated, getCollectionProductSlugs }
  • buildCheckoutEndpoints – returns { createCheckoutUrl }
  • betterShop – the full ready-made router

Compose Your Own Router

import { createRouter } from "better-call";
import {
  makeGetShop,
  buildStoreEndpoints,
  buildProductEndpoints,
} from "better-shop";

const getShop = makeGetShop({ /* optional ShopClientOptions */ });
const { getInfo } = buildStoreEndpoints(getShop);
const { getShowcasedProducts } = buildProductEndpoints(getShop);

export const myShop = createRouter(
  { getInfo, getShowcasedProducts },
  { openapi: { disabled: false, path: "/api/reference" } },
);

Use the Full Router

import { betterShop } from "better-shop";
export const router = betterShop({ /* optional ShopClientOptions */ });

Subpath Exports

Import individual modules via subpath exports for maximum control and tree-shaking.

import { makeGetShop } from "better-shop/shop/getShop";
import { buildProductEndpoints } from "better-shop/shop/products";
import { buildStoreEndpoints } from "better-shop/shop/store";
import { buildCollectionEndpoints } from "better-shop/shop/collections";
import { buildCheckoutEndpoints } from "better-shop/shop/checkout";
{
  "imports": {
    "better-shop/shop/products": "node_modules/better-shop/dist/shop/products.js"
  }
}

These subpaths are declared in package.json exports and point to the built ESM/CJS/DTS files under dist/shop/*.

OpenAPI Docs

  • OpenAPI docs are served at GET /api/reference with a Scalar UI.
  • Each endpoint includes responses with description: "OK" for 200 responses to satisfy better-call OpenAPI typing.

API Reference

Store Operations

  • GET /info: Get store metadata (name, domain, currency, etc.).
  • POST /info/clear-cache: Clear the store info cache.
  • POST /store-type: Determine store vertical and audience using LLM.

Product Operations

  • GET /products/all: Fetch all products.
  • GET /products/paginated: Fetch products with pagination (page, limit).
  • GET /products/showcased: Get products featured on the homepage.
  • GET /products/filters: Get product filters (options).
  • GET /products/:handle: Get a single product by handle.
  • POST /products/:handle/enriched: Enrich product data (markdown/HTML) using LLM.
  • POST /products/:handle/classify: Classify product audience/vertical.
  • POST /products/:handle/seo: Generate SEO content.

Collection Operations

  • GET /collections/all: Fetch all collections.
  • GET /collections/paginated: Fetch collections with pagination.
  • GET /collections/showcased: Get showcased collections.
  • GET /collections/:handle: Get a collection by handle.
  • GET /collections/:handle/products/paginated: Get products in a collection.

Checkout Operations

  • POST /checkout/url: Generate a pre-filled checkout URL.

Examples

# Get store info
curl -H "x-shop-domain: your-shop.myshopify.com" \
  http://localhost:3000/info

# Get paginated products
curl -H "x-shop-domain: your-shop.myshopify.com" \
  "http://localhost:3000/products/paginated?page=1&limit=10"

# Generate checkout URL
curl -X POST \
  -H "x-shop-domain: your-shop.myshopify.com" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "items": [{ "productVariantId": "gid://shopify/ProductVariant/123", "quantity": "1" }],
    "address": {
      "firstName": "John",
      "lastName": "Doe",
      "address1": "123 St",
      "city": "City",
      "zip": "12345",
      "country": "US",
      "province": "CA",
      "phone": "1234567890"
    }
  }' \
  http://localhost:3000/checkout/url
import { createClient } from "better-call/client";
import type { betterShop } from "better-shop";

type ShopRouter = ReturnType<typeof betterShop>;

const client = createClient<ShopRouter>({
  baseURL: "http://localhost:3000",
  headers: { "x-shop-domain": "your-shop.myshopify.com" }
});

const info = await client("/info");
const products = await client("/products/paginated", { query: { page: 1, limit: 10 } });

Endpoint Examples

type CurrencyCode = NonNullable<Intl.NumberFormatOptions["currency"]>;

type LocalizedPricing = {
  currency: string;
  priceFormatted: string;
  priceMinFormatted: string;
  priceMaxFormatted: string;
  compareAtPriceFormatted: string;
};

type ProductOption = {
  key: string;
  data: string[];
  name: string;
  position: number;
  values: string[];
};

type ProductVariantImage = {
  width: number;
  height: number;
  aspect_ratio?: number;
  id: number;
  src: string;
  position: number;
  productId: number;
  aspectRatio: number;
  variantIds: unknown[];
  createdAt: string;
  updatedAt: string;
  alt: string | null;
};

type ProductVariant = {
  id: string;
  platformId: string;
  name?: string;
  title: string;
  option1: string | null;
  option2: string | null;
  option3: string | null;
  options?: string[];
  sku: string | null;
  requiresShipping: boolean;
  taxable: boolean;
  featuredImage: ProductVariantImage | null;
  available: boolean;
  price: number;
  weightInGrams?: number;
  compareAtPrice: number;
  position: number;
  productId: number;
  createdAt?: string;
  updatedAt?: string;
};

type ProductImage = {
  width: number;
  height: number;
  aspect_ratio?: number;
  id: number;
  productId: number;
  alt: string | null;
  position: number;
  src: string;
  mediaType: "image" | "video";
  variantIds: unknown[];
  createdAt?: string;
  updatedAt?: string;
};

type MetaTag =
  | { name: string; content: string }
  | { property: string; content: string }
  | { itemprop: string; content: string };

type Product = {
  slug: string;
  handle: string;
  platformId: string;
  title: string;
  available: boolean;
  price: number;
  priceMin: number;
  priceVaries: boolean;
  compareAtPrice: number;
  compareAtPriceMin: number;
  priceMax: number;
  compareAtPriceMax: number;
  compareAtPriceVaries: boolean;
  discount: number;
  currency?: string;
  localizedPricing?: LocalizedPricing;
  options: ProductOption[];
  bodyHtml: string | null;
  active?: boolean;
  productType: string | null;
  tags: string[];
  vendor: string;
  featuredImage?: string | null;
  isProxyFeaturedImage: boolean | null;
  createdAt?: Date;
  updatedAt?: Date;
  variants: ProductVariant[] | null;
  images: ProductImage[];
  publishedAt: Date | null;
  seo?: MetaTag[] | null;
  metaTags?: MetaTag[] | null;
  displayScore?: number;
  deletedAt?: Date | null;
  storeSlug: string;
  storeDomain: string;
  embedding?: number[] | null;
  url: string;
  requiresSellingPlan?: boolean | null;
  sellingPlanGroups?: unknown;
  variantOptionsMap: Record<string, string>;
  enriched_content?: string;
};

type Collection = {
  id: string;
  title: string;
  handle: string;
  description?: string;
  image?: {
    id: number;
    createdAt: string;
    src: string;
    alt?: string;
  };
  productsCount: number;
  publishedAt: string;
  updatedAt: string;
};

type JsonLdEntry = Record<string, unknown>;

type CountryDetectionResult = {
  country: string;
  confidence: number;
  signals: string[];
  currencyCode?: string;
};

interface StoreInfo {
  name: string;
  domain: string;
  slug: string;
  title: string | null;
  description: string | null;
  logoUrl: string | null;
  socialLinks: Record<string, string>;
  contactLinks: {
    tel: string | null;
    email: string | null;
    contactPage: string | null;
  };
  headerLinks: string[];
  showcase: {
    products: string[];
    collections: string[];
  };
  jsonLdData: JsonLdEntry[] | undefined;
  techProvider: {
    name: string;
    walletId: string | undefined;
    subDomain: string | null;
  };
  country: CountryDetectionResult["country"];
}

type ProductClassification = {
  audience: "adult_male" | "adult_female" | "kid_male" | "kid_female" | "generic";
  vertical: "clothing" | "beauty" | "accessories" | "home-decor" | "food-and-beverages";
  category?: string | null;
  subCategory?: string | null;
};

type SEOContent = {
  metaTitle: string;
  metaDescription: string;
  shortDescription: string;
  longDescription: string;
  tags: string[];
  marketingCopy: string;
};

type StoreTypeBreakdown = Partial<
  Record<
    "adult_male" | "adult_female" | "kid_male" | "kid_female" | "generic",
    Partial<Record<"clothing" | "beauty" | "accessories" | "home-decor" | "food-and-beverages", string[]>>
  >
>;

type Filters = Record<string, string[]>;
type CheckoutUrl = { url: string };

GET /info

  • Returns store metadata.
curl -H "x-shop-domain: your-shop.myshopify.com" \
  http://localhost:3000/info
const res = await client("/info");
const info = res.data as StoreInfo;

POST /info/clear-cache

  • Clears cached store info.
curl -X POST -H "x-shop-domain: your-shop.myshopify.com" \
  http://localhost:3000/info/clear-cache
const cleared = await client("/info/clear-cache");
// { success: true }

POST /store-type

  • Determines store vertical/audience.
curl -X POST -H "x-shop-domain: your-shop.myshopify.com" \
  -H "Content-Type: application/json" \
  -d '{ "model": "gpt-4o-mini" }' \
  http://localhost:3000/store-type
const res = await client("/store-type", { body: { model: "gpt-4o-mini" } });
const storeType = res.data as StoreTypeBreakdown;

GET /products/all

  • Returns all products.
curl -H "x-shop-domain: your-shop.myshopify.com" \
  "http://localhost:3000/products/all?currency=USD"
const res = await client("/products/all", { query: { currency: "USD" } });
const products = res.data as Product[];

GET /products/paginated

  • Returns products with pagination.
curl -H "x-shop-domain: your-shop.myshopify.com" \
  "http://localhost:3000/products/paginated?page=2&limit=10&currency=USD"
const res = await client("/products/paginated", {
  query: { page: 2, limit: 10, currency: "USD" }
});
const products = res.data as Product[];

GET /products/showcased

  • Returns featured products.
curl -H "x-shop-domain: your-shop.myshopify.com" \
  http://localhost:3000/products/showcased
const res = await client("/products/showcased");
const showcased = res.data as Product[];

GET /products/filters

  • Returns available product filters.
curl -H "x-shop-domain: your-shop.myshopify.com" \
  http://localhost:3000/products/filters
const res = await client("/products/filters");
const filters = res.data as Filters;

GET /products/:handle

  • Returns a single product by handle.
curl -H "x-shop-domain: your-shop.myshopify.com" \
  http://localhost:3000/products/example-handle
const res = await client("/products/:handle", { params: { handle: "example-handle" } });
const product = res.data as Product; // 404/500 if missing

POST /products/:handle/enriched

  • Returns enriched product content.
curl -X POST -H "x-shop-domain: your-shop.myshopify.com" \
  -H "Content-Type: application/json" \
  -d '{ "model": "gpt-4o-mini", "outputFormat": "markdown" }' \
  http://localhost:3000/products/example-handle/enriched
const res = await client("/products/:handle/enriched", {
  params: { handle: "example-handle" },
  body: { model: "gpt-4o-mini", outputFormat: "markdown" }
});
const enrichedProduct = res.data as Product; // `enriched_content` may be present

POST /products/:handle/classify

  • Classifies product audience/vertical.
curl -X POST -H "x-shop-domain: your-shop.myshopify.com" \
  -H "Content-Type: application/json" \
  -d '{ "model": "gpt-4o-mini" }' \
  http://localhost:3000/products/example-handle/classify
const res = await client("/products/:handle/classify", {
  params: { handle: "example-handle" },
  body: { model: "gpt-4o-mini" }
});
const classification = res.data as ProductClassification;

POST /products/:handle/seo

  • Generates product SEO content.
curl -X POST -H "x-shop-domain: your-shop.myshopify.com" \
  -H "Content-Type: application/json" \
  -d '{ "model": "gpt-4o-mini" }' \
  http://localhost:3000/products/example-handle/seo
const res = await client("/products/:handle/seo", {
  params: { handle: "example-handle" },
  body: { model: "gpt-4o-mini" }
});
const seo = res.data as SEOContent;

Advanced Usage

Merge with Existing Routers

Compose endpoints from this package with your own endpoints into a single router.

import { createRouter } from "better-call";
import { makeGetShop, buildStoreEndpoints, buildProductEndpoints } from "better-shop";

const getShop = makeGetShop();
const store = buildStoreEndpoints(getShop);
const products = buildProductEndpoints(getShop);

// Your custom endpoints
import { createEndpoint } from "better-call";
const health = createEndpoint("/health", { method: "GET" }, async () => ({ ok: true }));

export const app = createRouter(
  { ...store, ...products, health },
  { openapi: { disabled: false, path: "/api/reference" } },
);

Customize OpenAPI UI

Set the docs path and Scalar UI settings when creating the router.

import { betterShop } from "better-shop";

export const router = betterShop();
// or compose manually
// export const router = createRouter(endpoints, {
//   openapi: {
//     disabled: false,
//     path: "/api/reference",
//     scalar: { title: "Better Shop API", theme: "dark" },
//   },
// });

Add a Custom Endpoint Using getShop

import { createRouter, createEndpoint } from "better-call";
import { makeGetShop } from "better-shop";

const getShop = makeGetShop();

const getCurrency = createEndpoint("/currency", { method: "GET" }, async (ctx) => {
  const shop = getShop(ctx.headers);
  const info = await shop.getInfo();
  return { currency: info.currency };
});

export const router = createRouter({ getCurrency }, { openapi: { disabled: false } });

Tree-Shaking and Bundle Size

  • Prefer ESM and named imports to allow bundlers to remove unused exports.
  • Import only the builders you need, e.g. buildProductEndpoints.

Integration Testing

Use better-call/client to test your composed router end-to-end.

import { createClient } from "better-call/client";
import type { betterShop } from "better-shop";

type ShopRouter = ReturnType<typeof betterShop>;

const client = createClient<ShopRouter>({
  baseURL: "http://localhost:3000",
  headers: { "x-shop-domain": "your-shop.myshopify.com" },
});

const res = await client("/info");
if (res.error) throw new Error(String(res.error));

GET /collections/all

  • Returns all collections.
curl -H "x-shop-domain: your-shop.myshopify.com" \
  http://localhost:3000/collections/all
const res = await client("/collections/all");
const collections = res.data as Collection[];

GET /collections/paginated

  • Returns paginated collections.
curl -H "x-shop-domain: your-shop.myshopify.com" \
  "http://localhost:3000/collections/paginated?page=1&limit=10"
const res = await client("/collections/paginated", { query: { page: 1, limit: 10 } });
const collections = res.data as Collection[];

GET /collections/:handle

  • Returns a collection by handle.
curl -H "x-shop-domain: your-shop.myshopify.com" \
  http://localhost:3000/collections/example-handle
const res = await client("/collections/:handle", { params: { handle: "example-handle" } });
const collection = res.data as Collection;

GET /collections/:handle/products/all

  • Returns all products in a collection.
curl -H "x-shop-domain: your-shop.myshopify.com" \
  "http://localhost:3000/collections/example-handle/products/all?currency=USD"
const res = await client("/collections/:handle/products/all", {
  params: { handle: "example-handle" },
  query: { currency: "USD" }
});
const products = res.data as Product[];

GET /collections/:handle/products/paginated

  • Returns paginated products within a collection.
curl -H "x-shop-domain: your-shop.myshopify.com" \
  "http://localhost:3000/collections/example-handle/products/paginated?page=1&limit=10&currency=USD"
const res = await client("/collections/:handle/products/paginated", {
  params: { handle: "example-handle" },
  query: { page: 1, limit: 10, currency: "USD" }
});
const products = res.data as Product[];

GET /collections/:handle/slugs

  • Returns product slugs within a collection.
curl -H "x-shop-domain: your-shop.myshopify.com" \
  http://localhost:3000/collections/example-handle/slugs
const res = await client("/collections/:handle/slugs", { params: { handle: "example-handle" } });
const slugs = res.data as string[];

POST /checkout/url

  • Generates a pre-filled checkout URL.
curl -X POST -H "x-shop-domain: your-shop.myshopify.com" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "items": [{ "productVariantId": "gid://shopify/ProductVariant/123", "quantity": "1" }],
    "address": { "firstName": "John", "lastName": "Doe", "address1": "123 St", "city": "City", "zip": "12345", "country": "US", "province": "CA", "phone": "1234567890" }
  }' \
  http://localhost:3000/checkout/url
const res = await client("/checkout/url", {
  body: {
    email: "[email protected]",
    items: [{ productVariantId: "gid://shopify/ProductVariant/123", quantity: "1" }],
    address: { firstName: "John", lastName: "Doe", address1: "123 St", city: "City", zip: "12345", country: "US", province: "CA", phone: "1234567890" }
  }
});
const checkout = res.data as CheckoutUrl;

License

MIT