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 & path | Purpose |
|---|---|
GET /api/store/products | List, 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
searchis present, it performs a name-prefix search and ignorescategoryandcursor. Results are not paginated. - List mode — otherwise, it returns a category-filterable, cursor-paginated page of the catalog.
Query parameters
| Param | Type | Default | Notes |
|---|---|---|---|
search | string | — | 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. |
category | string (slug) | — | A category slug from GET /api/store/categories. An unknown slug returns 400. List mode only. |
limit | integer | 20 | Page 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). |
cursor | string (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
{
"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
}| Field | Type | Notes |
|---|---|---|
products | PublicProduct[] | The page (or search results). |
nextCursor | string | null | Pass as ?cursor= to fetch the next page. null when the catalog is exhausted or in search mode. |
totalCount | number (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
curl "https://your-store.example.com/api/store/products?limit=2"{
"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
| Param | Type | Notes |
|---|---|---|
id | string | The 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.
curl "https://your-store.example.com/api/store/products/sku_1a2b3c"{
"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:
{ "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.
| Field | Type | Description |
|---|---|---|
id | string | Catalog id. Use as the {id} path segment. |
productName | string | Display name. Empty string when unset. |
brand | string | Brand name. Empty string when unset. |
price | number | null | Unit price. null for quote-only catalogs without prices (or when the stored price is not a finite number). |
categories | string[] | Category slugs (see GET /api/store/categories). Empty array when uncategorized. |
description | string | null | Long description, or null when empty/unset. |
primaryImageURL | string | null | Primary image URL, or null when none. |
pdfLink | string | null | Link to a spec sheet / datasheet PDF, or null when none. |
inStock | boolean | null | true/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=300This 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).
| Status | Endpoint | error message | When |
|---|---|---|---|
400 | list/search | Search term must be at least 2 characters | search present but shorter than 2 chars after trimming. |
400 | list/search | Unknown category | category is not a known slug. |
400 | list/search | Invalid cursor | cursor is not a path-safe segment: empty, over 512 chars, containing /, or ./... A well-formed but stale cursor ends the listing instead. |
400 | by-id | Invalid product id | The {id} path segment is not a plausible document id. |
404 | by-id | Product not found | No product exists with that id. |
500 | any | Internal server error | Unexpected server-side failure. |
{ "error": "Unknown category" }