Ecommerce Boilerplate
API Reference

Products API

List, search, paginate, and fetch public products.

The Products API is the public, read-only surface of the store catalog. It exposes only data that is already publicly readable per firestore.rules, reshaped into a stable PublicProduct wire format so native apps and other frontends can consume the catalog without depending on internal storage details.

There are two endpoints:

Method & pathPurpose
GET /api/store/productsList, filter by category, paginate, or search products.
GET /api/store/products/{id}Fetch a single product by its catalog id.

Both send a shared cache header (see Caching). Internal fields (such as the derived productNameLower search index) are stripped, and raw inventory counts are collapsed into an inStock flag so stock levels are never exposed.

This API is read-only. In the default quote-based flow, shoppers browse this catalog, add items to a cart, and submit a quote request — no prices or payment are required. The price field may be null for quote-only catalogs.

GET /api/store/products

Public product listing and search. The endpoint has two mutually exclusive modes selected by the search param:

  • Search mode — when search is present, it performs a name-prefix search and ignores category and cursor. Results are not paginated.
  • List mode — otherwise, it returns a category-filterable, cursor-paginated page of the catalog.

Query parameters

ParamTypeDefaultNotes
searchstring—Name prefix search. Must be at least 2 characters (after trimming). When present, category and cursor are ignored and results are not paginated. A 1-character term returns 400.
categorystring (slug)—A category slug from GET /api/store/categories. An unknown slug returns 400. List mode only.
limitinteger20Page size, clamped to the range 1..50. Out-of-range or malformed values are normalized, never rejected. Applies to both modes (search caps the number of results).
cursorstring (opaque)—The nextCursor from the previous response, passed back verbatim. What is inside it is the backend's business — a product id on Firebase, a base64url keyset token on the SQL adapters — so never construct one yourself. A value that is not a path-safe segment returns 400; a well-formed cursor that no longer matches a row is not an error, it simply ends the listing with an empty page. List mode only.

limit is parsed with a clamp helper: missing or non-numeric input falls back to the default 20, and any value below 1 or above 50 is pulled back into range. So limit=0 becomes 1, limit=999 becomes 50, and limit=abc becomes 20.

Response

200 OK
{
  "products": [
    {
      "id": "sku_1a2b3c",
      "productName": "Titan 9000 Hydraulic Pump",
      "brand": "Titan",
      "price": 1299.99,
      "categories": ["pumps", "hydraulics"],
      "description": "High-flow industrial hydraulic pump.",
      "primaryImageURL": "https://ecommerce-boilerplate.com/img/titan-9000.jpg",
      "pdfLink": "https://ecommerce-boilerplate.com/specs/titan-9000.pdf",
      "inStock": true
    }
  ],
  "nextCursor": "sku_1a2b3c",
  "totalCount": 128
}
FieldTypeNotes
productsPublicProduct[]The page (or search results).
nextCursorstring | nullPass as ?cursor= to fetch the next page. null when the catalog is exhausted or in search mode.
totalCountnumber (optional)Total products matching the filter. Present in list mode only — omitted from search responses.

Every backend hands back a cursor even on a full final page, so the route adds a check of its own: nextCursor survives only when the page came back completely full (products.length === limit). A short page means the catalog is exhausted and nextCursor is null. Drive pagination from nextCursor, not from counting rows against totalCount.

Examples

Terminal
curl "https://your-store.example.com/api/store/products?limit=2"
200 OK
{
  "products": [
    { "id": "sku_a", "productName": "Alpha Widget", "brand": "Wavecrest", "price": 19.99, "categories": ["widgets"], "description": null, "primaryImageURL": null, "pdfLink": null, "inStock": true },
    { "id": "sku_b", "productName": "Beta Widget", "brand": "Wavecrest", "price": 24.99, "categories": ["widgets"], "description": null, "primaryImageURL": null, "pdfLink": null, "inStock": null }
  ],
  "nextCursor": "sku_b",
  "totalCount": 57
}

GET /api/store/products/{id}

Fetch a single public product by its catalog id.

Path parameters

ParamTypeNotes
idstringThe product's catalog id. Must be a plausible Firestore document id (non-empty, ≤ 512 chars, no /, and not . or ..). A malformed id returns 400.

Response

On success the body is a bare PublicProduct object — not wrapped in a products array.

Terminal
curl "https://your-store.example.com/api/store/products/sku_1a2b3c"
200 OK
{
  "id": "sku_1a2b3c",
  "productName": "Titan 9000 Hydraulic Pump",
  "brand": "Titan",
  "price": 1299.99,
  "categories": ["pumps", "hydraulics"],
  "description": "High-flow industrial hydraulic pump.",
  "primaryImageURL": "https://ecommerce-boilerplate.com/img/titan-9000.jpg",
  "pdfLink": "https://ecommerce-boilerplate.com/specs/titan-9000.pdf",
  "inStock": true
}

When no product has that id, the endpoint returns 404:

404 Not Found
{ "error": "Product not found" }

PublicProduct schema

The public wire shape of a product. Every field is defensively narrowed from the internal record, so absent or malformed values become empty strings, empty arrays, or null rather than leaking internal shapes.

FieldTypeDescription
idstringCatalog id. Use as the {id} path segment.
productNamestringDisplay name. Empty string when unset.
brandstringBrand name. Empty string when unset.
pricenumber | nullUnit price. null for quote-only catalogs without prices (or when the stored price is not a finite number).
categoriesstring[]Category slugs (see GET /api/store/categories). Empty array when uncategorized.
descriptionstring | nullLong description, or null when empty/unset.
primaryImageURLstring | nullPrimary image URL, or null when none.
pdfLinkstring | nullLink to a spec sheet / datasheet PDF, or null when none.
inStockboolean | nulltrue/false when inventory is tracked for this product; null when inventory is not tracked (the product is always purchasable). Raw stock counts are never exposed.

Distinguish the two null semantics carefully. price: null means the catalog is quote-based and has no price to show — render a "Request a quote" affordance instead of a price. inStock: null means stock is untracked, i.e. always available — it does not mean out of stock. Only inStock: false means out of stock.

Caching

All three product responses (list, search, detail) send the same header:

Cache-Control: public, s-maxage=60, stale-while-revalidate=300

This value is defined once as PRODUCT_CACHE_CONTROL in src/lib/api/store.ts. It lets a CDN serve products from cache for 60 seconds and continue serving stale content for up to 5 minutes while revalidating in the background. The routes are dynamic, as every route handler is by default, so caching is delegated to that one shared header rather than to Next.js.

Error responses

Every error is a JSON object of the form { "error": "<message>" } with the appropriate status code (built by the shared jsonError helper).

StatusEndpointerror messageWhen
400list/searchSearch term must be at least 2 characterssearch present but shorter than 2 chars after trimming.
400list/searchUnknown categorycategory is not a known slug.
400list/searchInvalid cursorcursor is not a path-safe segment: empty, over 512 chars, containing /, or ./... A well-formed but stale cursor ends the listing instead.
400by-idInvalid product idThe {id} path segment is not a plausible document id.
404by-idProduct not foundNo product exists with that id.
500anyInternal server errorUnexpected server-side failure.
400 Bad Request
{ "error": "Unknown category" }

On this page