I build a browser-side customizer for printed sneakers. You upload artwork, it gets sliced across the shoe panels, and you preview it on the actual shoe before ordering: bukefootwear.com. The geometry is in the first post, the magnification math in the second, the render pipeline in the third, the deploy budget in the fourth.
This one is about the storefront shell around it. Our release is a folder of static files on Cloudflare Pages. There is no router, no server, no /product/[slug] route. Eight shoe styles are served by one HTML file:
https://bukefootwear.com/product?product=KJ1
https://bukefootwear.com/product?product=MT1
...
That works fine for humans and it is a trap for everything else. Four things broke, and none of them threw an error.
1. Canonical collapse: eight pages that all claim to be the same page
The first version had no canonical at all. Then I added one — statically, into the <head> of product.html:
<link rel="canonical" href="https://bukefootwear.com/product">
Eight product URLs, one canonical target. To a crawler that says "these eight pages are duplicates of one page, index that one instead." Every individual style gets dropped. The page that ranks is a page with no product on it until JavaScript runs.
The fix is to make the canonical a function of the query string, and to accept that it is now JavaScript's job:
const code = new URLSearchParams(location.search).get('product');
const canonical = document.createElement('link');
canonical.rel = 'canonical';
canonical.href = 'https://bukefootwear.com/product?product=' + encodeURIComponent(code);
document.head.append(canonical);
Two rules I now follow because of this:
- Never ship a static canonical on a query-param page. A wrong canonical is worse than a missing one, and it is invisible in the browser.
-
The static HTML must never contradict what the JS writes. If a
<link rel="canonical">is already in the document,appendgives you two canonicals and Google picks the first. The static one wins, and it is the wrong one.
The same shape of bug hit our designer page: it had <html lang="zh-CN"> and a Chinese <title>, and designer.js overwrote the title at runtime. Editing the HTML changed nothing for real visitors because the JS ran a millisecond later. Whatever the crawler reads statically and whatever the visitor sees have to be the same claim.
2. Zero structured data, on the pages that sell
Eight commercial pages, and the count of JSON-LD blocks across all of them was:
$ grep -c "application/ld+json" product.html
0
Not "malformed", not "warned in Search Console" — simply absent. Meanwhile the blog pages had it. The money pages were the only ones without a machine-readable name, price, or availability.
The reason was structural, not laziness: JSON-LD is per-product data, and a single static file cannot hold eight products' prices honestly. So it has to be generated where the product is known:
const ld = {
'@context': 'https://schema.org',
'@type': 'Product',
name: `BUKE ${product.name} ${code}`,
sku: code,
brand: { '@type': 'Brand', name: 'BUKE STUDIO' },
material: String(product.material || '').replace(/\s*[\r\n]+\s*/g, ', ').trim(),
};
if (images.length) ld.image = images.slice(0, 6);
if (fixedPrice && Number.isFinite(Number(fixedPrice.price))) {
ld.offers = {
'@type': 'Offer',
price: Number(fixedPrice.price),
priceCurrency: 'USD',
url: 'https://bukefootwear.com/product?product=' + encodeURIComponent(code),
availability: fixedPrice.purchasable
? 'https://schema.org/InStock'
: 'https://schema.org/PreOrder',
itemCondition: 'https://schema.org/NewCondition',
};
}
const s = document.createElement('script');
s.type = 'application/ld+json';
s.textContent = JSON.stringify(ld);
document.head.append(s);
Two details that matter more than the schema itself:
The offers block is conditional. If the price is missing, emitting "price": undefined gives you {"@type":"Offer"} with no price — an invalid Offer that is worse than no Offer. Wrap the whole object, not the field.
material came from a text field with newlines in it. It arrived as "Upper: microfiber\nSole: rubber". Valid JSON, valid string, and a value no consumer wants to display. Normalise at the boundary, not at the renderer.
Verification has to be done in a real browser, because the block does not exist in curl output:
Product sku=KJ1 price=98 USD InStock imgs=6
Product sku=MT1 price=68 USD InStock imgs=6
Product sku=DF1 price=88 USD InStock imgs=6
That is the same lesson as the canonical: if JavaScript writes it, only a JS-executing check counts. I have wasted more time on curl | grep returning empty while the browser was correct than on any other class of bug in this project.
3. Social cards never render, because crawlers do not run your JS
This one is specific and it bit us during a campaign. We were pushing a design challenge on Threads, X and Instagram. When someone shares a link, the platform fetches the URL, reads og:title, og:description, og:image from the raw HTML — and does not execute JavaScript.
Result: the links we were paying attention to, shared all day, rendered as bare text. No image. The tags we had were set by JS, therefore invisible to every scraper:
$ curl -s https://bukefootwear.com/challenge | grep -c 'og:image'
0
Three of our most-promoted pages (/product, /designer, /challenge) had zero or partial Open Graph tags. The homepage had them. The campaign pages did not.
The fix is unglamorous: static tags in the HTML, per page, with a real 1200px image, plus the Twitter variants because X does not read Open Graph:
<meta property="og:image" content="https://bukefootwear.com/assets/studio-v4/XB1-pair-1200.webp">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="1200">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://bukefootwear.com/assets/studio-v4/XB1-pair-1200.webp">
And here is the honest trade-off: on a query-param storefront the static og:image is one image for all eight products. Scrapers cannot see the per-product image the JS picks. You either accept a generic card, or you generate eight real HTML files at build time.
I accepted the generic card for now. It is a small loss and an enormous improvement over nothing. The general rule: anything that must be readable by a machine that does not run JS (crawlers, social scrapers, link previews, RSS, some ad platforms) cannot be produced by client-side logic. Audit those separately; they are not part of your "the page works" test.
4. The price lived in four places
This is the one that would eventually have cost real money.
Prices for the eight styles were written into the product page markup, into a cart script, into a checkout calculation, and into one more place I found by grepping. They agreed, because I had been careful, which is exactly why the bug had not surfaced yet.
The data does not look risky:
// one module, the only place a price is allowed to be typed
window.BukePricing.products = {
KJ1: { price: 98, purchasable: true },
MT1: { price: 68, purchasable: true },
...
};
But a made-to-order shop has a twist: price and freight are different rules that both change. A pair costs $98. Shipping is charged once for the first pair and 40% for each additional one in the same order — so the number a customer sees depends on order composition, not on the product. Two formulas, two owners, and if either one is duplicated you get a cart that disagrees with the product page and nobody notices until a customer does.
What made this safe was not discipline. It was making the number impossible to type twice:
- The product page renders price from the module.
- The cart reads the same module.
- The JSON-LD reads the same module.
- Freight lives in exactly one function, and its constants are named, so "we changed the EU rate" is a one-line diff.
I now test pricing the same way I test pixels: change one value in the source of truth, reload three pages, and confirm all three moved. If one of them does not, I found a duplicate.
What a router would and would not have fixed
The reflexive answer is "use a framework with real routes." Eight static HTML files generated from one template would genuinely fix three of the four problems: real canonicals, real per-product Open Graph, no JS-dependent metadata. That is a build step, not a rewrite, and it is the correct destination.
It would not fix the fourth. A router does not create a single source of truth for price; it just moves the duplication. The rendering layer is the easy part — it is the data ownership that decides whether a storefront can be trusted with money.
Until then, the checklist I actually use before shipping a page that sells something:
-
curlthe URL and confirm canonical, description, title and Open Graph are all present in the raw HTML. - Render it in a browser and confirm the JS-written versions match what
curlreturned, or that you accept the difference knowingly. - Grep for the number. If the price appears in more than one file, that is the bug, whether or not it is wrong today.
- Count JSON-LD blocks. Zero on a commercial page is a decision you did not make on purpose.
The product pages these lessons came from are here, if you want to see the result: KJ1 low-top, MT1 slipper, and the designer that does the slicing.
Top comments (0)