Acme Store Docs
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 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 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
searchstringName 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 (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

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://example.com/img/titan-9000.jpg",
      "pdfLink": "https://example.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.

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

Terminal
curl "https://your-store.example.com/api/store/products?limit=2"
200 OK
{
  "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:

Terminal
curl "https://your-store.example.com/api/store/products?limit=2&cursor=sku_b"
200 OK
{
  "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.

Terminal
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.

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

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 and as a pagination cursor.
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 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).

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 malformed or does not reference an existing product.
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