Build a product page
One firearm page, end to end: name to id, the record, its media, the sellers who stock it, and what to show where a field is missing.
The shape of the page
Four calls. The last three do not depend on each other, so they go together rather than one after another.
- Resolve the nameA URL slug or a search term becomes one catalog id, with a status you can act on.
- Fetch the recordThe full specification, its manufacturer, calibers and operators, plus the version to cache against.
- Fetch the mediaPhotographs, renders and the line-art silhouette, each with its own credit line.
- Fetch the offersSellers who ship to this reader, with prices in minor units and a counted outbound link.
- Cache the resultStore the ETag and the version beside it, and serve the page from your cache until one of them moves.
The whole loader
Written with the TypeScript SDK, and it runs on your server, so the key never reaches the browser. Keys in production covers that. The resolve step is the one worth reading twice, and resolve before you read says why.
// One product page. Four calls, all cacheable, none of them// depending on an answer the previous one did not already give.export async function loadFirearmPage(query, region) { // 1. A name becomes an id. Never search-and-take-the-first-hit. const { data: resolved } = await gunspec.firearms.resolve(query) if (resolved.status !== 'resolved') return { notFound: true, resolved } const id = resolved.firearmId // 2. The record, the media and the sellers are independent of each // other, so they go together rather than one after another. const [firearm, media, offers] = await Promise.all([ gunspec.firearms.get(id), gunspec.firearms.listMedia(id), // Sellers who ship where this visitor is; the rest are noise. gunspec.firearms.getOffers(id, { region }), ]) return { firearm: firearm.data, // Fall back down the kinds rather than rendering a gap: a // photograph does not exist for every record, and the line-art // silhouette does for most of them and scales to any size. hero: ['photo', 'render', 'silhouette'].reduce( (found, kind) => found ?? media.data.find((m) => m.kind === kind), undefined, ), // priceCents is an integer in minor units. Never parse it as a // float, and format it in the currency the offer names. offers: offers.data.map((offer) => ({ ...offer, price: new Intl.NumberFormat(locale, { style: 'currency', currency: offer.currency }) .format(offer.priceCents / 100), // Send readers through the counted link, not straight to // the shop: it is what lets a seller see their placement work. href: `${API}/v1/out/${offer.clickId}`, })), // Store these two beside your copy: `version` says whether the // record changed, the ETag revalidates it for nothing. version: firearm.data.version, }}What the page costs
Four requests on a cold cache and zero on a warm one. A page that spends four requests per view instead is the most common way a plan runs out.
- Cache the loader’s result, not the individual calls: it is the page you serve, and it is invalidated by one
versioncomparison. Caching has the lifetimes. - Revalidate with the stored ETag rather than refetching. A 304 costs nothing against your cap, as staying inside the plan explains.
- Render from your cache while you revalidate. Nobody should wait on our latency to see a specification that has not changed since Tuesday.
Show what you have, not what you assume
A missing field is null. That means we have not sourced it, not that the value is zero. Printing 0 mm for an unknown barrel length is the one error a reader cannot detect and will not forgive. Data quality reports how well each catalog is covered.
// A spec sheet that does not invent what it does not have.function rows(firearm) { return SPEC_FIELDS // A null is "we have not sourced this", not zero. Printing 0 mm // for an unknown barrel length is the one mistake a reader // cannot detect and will not forgive. .filter((field) => firearm[field.key] != null) .map((field) => ({ label: field.label, value: field.format(firearm[field.key]) }))} // Confidence is about the record, not any one figure. Show the// sources the record carries beside the numbers, and link the// report route so a reader who knows better can tell us.const showSources = firearm.dataConfidence < 0.8 && firearm.sources?.length > 0Media, credit and outbound links
Assets come with the terms they are usable under; the media page lists the kinds and rights holders the terms. Two rules are worth stating here.
- Render the
creditthe asset carries. It is not decoration; it is the condition the image is served under. - Send readers to a seller through
/v1/out/{clickId}rather than straight to the shop, so the visit is counted for the seller whose listing you showed. - Fall back down the kinds (photo, render, silhouette) rather than rendering a gap. Most records have line art; not all have a photograph.