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 are marked dynamic = "force-dynamic" and 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 (product id) | — | The id of the last item of the previous page. Must reference an existing product; a malformed or non-existent id returns 400. 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://example.com/img/titan-9000.jpg",
"pdfLink": "https://example.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. |
nextCursor is the id of the last product in the returned page, but only when the page is completely full (products.length === limit). A short page means the collection is exhausted, so nextCursor is null. Always drive pagination from nextCursor rather than assuming more pages exist.
Examples
curl "https://your-store.example.com/api/store/products?limit=2"{
"products": [
{ "id": "sku_a", "productName": "Alpha Widget", "brand": "Acme", "price": 19.99, "categories": ["widgets"], "description": null, "primaryImageURL": null, "pdfLink": null, "inStock": true },
{ "id": "sku_b", "productName": "Beta Widget", "brand": "Acme", "price": 24.99, "categories": ["widgets"], "description": null, "primaryImageURL": null, "pdfLink": null, "inStock": null }
],
"nextCursor": "sku_b",
"totalCount": 57
}Feed the previous response's nextCursor back as cursor:
curl "https://your-store.example.com/api/store/products?limit=2&cursor=sku_b"{
"products": [
{ "id": "sku_c", "productName": "Gamma Widget", "brand": "Acme", "price": 29.99, "categories": ["widgets"], "description": null, "primaryImageURL": null, "pdfLink": null, "inStock": true }
],
"nextCursor": null,
"totalCount": 57
}A null nextCursor here signals the last page.
curl "https://your-store.example.com/api/store/products?category=pumps&limit=20"The category value must be a known slug from GET /api/store/categories; otherwise the endpoint returns 400 Unknown category.
curl "https://your-store.example.com/api/store/products?search=tit"{
"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://example.com/img/titan-9000.jpg", "pdfLink": "https://example.com/specs/titan-9000.pdf", "inStock": true }
],
"nextCursor": null
}Note there is no totalCount in search responses, and nextCursor is always null.
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://example.com/img/titan-9000.jpg",
"pdfLink": "https://example.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 and as a pagination cursor. |
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 themselves are force-dynamic, so caching is delegated to the shared header rather than Next.js's built-in data cache.
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 malformed or does not reference an existing product. |
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" }