← Mike Sanborn

What an AI agent gets wrong building a Medusa storefront

I built a multi-region Next.js storefront on Medusa 2.0 with an AI agent doing the implementation and me directing and reviewing. Here's the architecture, nine categories of defect the agent produced, and a profiling arc where the score barely moved and the fix was still real.

By Mike Sanborn · August 31, 2026

1. The premise

Most writing about AI-assisted development is either “the agent built my app in an afternoon” or “the agent is useless.” Both are marketing. The useful version is specific: what the agent does well, what it gets wrong every time, and what review process catches the difference.

This is that write-up, built around a real storefront — Amber Hour Coffee Co., a specialty roaster selling into the US in USD and the EU in EUR, with a variant-heavy catalog and mobile-first traffic.

The implementation was agent-driven. The architecture, the product model, the infrastructure decisions, and every review pass were mine. That division is the subject.

Live: https://www.amberhour.coffee · Source: github.com/msanbo/coffee-demo-store

2. What was being built

Requirements a real roaster would hand you, not sample-data requirements:

The Medusa Next.js starter gives you a working store. It gives you none of the above.

3. Architecture

Regions are not countries. The most consequential modeling decision in the build. Amber Hour has two regions — United States on USD, Europe on EUR — with six countries mapped across them, five in the EU region. Countries drive URL routing and the customer-facing selector. Regions drive currency, pricing, and tax. Collapse them into one concept and you either duplicate price sets per country or you can’t give France and Germany their own URLs. Routing is a [countryCode] dynamic segment at the App Router root, with the country resolving to a region before any product query runs.

Server-first rendering with deliberate client boundaries. Product and region data are fetched on the server. Variant selection, cart, and the region switcher are the client edge. Related products and the image gallery sit behind Suspense with skeleton fallbacks, so primary content paints without waiting on secondary queries.

The product model carries operational reality. Nine variants per product on a consistent SKU scheme (KENYA-AA-WH-2LB), with real shipping weights in grams — 908, 2270, 4540. Prices ladder rather than scale: Kenya AA at $48, $106, and $187 works out to $24.00, $21.20, and $18.70 per pound. Demo stores price everything at $10. Real ones have a pricing strategy, SKUs a warehouse can pick, and weights a shipping calculator can use.

Object storage on a domain I control. Images on Cloudflare R2 behind cdn.amberhour.coffee. R2 over S3 because image bandwidth is the recurring cost that matters on a storefront and R2 has no egress fees; a bound custom domain rather than the r2.dev development endpoint, which is rate-limited and not intended for production.

These were my calls. The agent implemented them quickly and mostly correctly. What follows is where that broke down.

4. What the agent got wrong

Not a list of bugs — a list of categories, because the categories predict what to look for next time.

4.1 Configuration that only works locally

Every page shipped og:image and twitter:image pointing at https://localhost:8000/.... Nothing errors. Nothing looks wrong in development. Production renders every social share with a broken preview — on a site whose distribution model is getting a link pasted into Slack and LinkedIn.

Agents optimize for “runs on my machine,” because that’s the feedback loop they’re in. Anything that only fails on a different origin is invisible to them.

4.2 Silent omissions inside things that look complete

The product route’s generateMetadata returned a title and nothing else — no description, no Open Graph, no Twitter card. The Contact and Why Us pages had the full set. So metadata “worked,” on the pages that mattered least, and the highest-value SEO surface in the store shipped bare.

The agent produces the shape of the correct thing, and the incomplete instance is the one you don’t happen to open.

4.3 Framework defaults left in place

The starter’s title template survived on the product route long after being replaced everywhere else, so flagship pages read ... | Medusa Store. Image uploads defaulted to the API server’s local filesystem, which put a hostname derived from the backend’s IP address into every image URL.

Defaults are invisible precisely because they work.

4.4 Data-model shortcuts that cost you later

Roast level and process went into product.metadata. It works, it renders, and it quietly makes faceted filtering impossible, because metadata isn’t queryable as a filter. The store page has sort — newest, price ascending, price descending — but can’t filter by roast level without a data migration. Tags would have cost nothing up front.

The agent solved the stated problem — display these attributes — without modeling for the obvious next requirement. That’s judgment, and judgment is what you’re supposed to be supplying.

4.5 Output that is correct by accident

Option values shipped with rank: null across the board, every variant at variant_rank: 0. Medusa returns them in whatever order it returns them. Bag sizes happened to render 2 lb, 5 lb, 10 lb — by insertion luck, not design. Grind and Bag Size swapped positions between products.

The sneakiest category: the output is right, so nothing draws your attention, and the nondeterminism surfaces later on a different dataset or after a reseed.

4.6 Regressions with no visible symptom

The site-wide banner was a 1936-pixel-wide PNG rendered full-bleed with priority on every route and no sizes attribute, so a phone downloaded close to the desktop asset. Priority makes an image load sooner, not smaller. If the asset is wrong for the viewport, priority just fetches the wrong thing faster.

Separately, the LCP image on the catalog page was lazy-loaded. The one image that must not wait, waiting.

4.7 Partial fixes that create new defects

A breadcrumb rendering Kenya Aa — title-casing the slug instead of using the product title — got “fixed” by removing the trailing crumb entirely. The symptom went away. So did the feature.

When you report a symptom rather than a cause, verify the fix addressed the cause.

4.8 Incomplete migrations

Moving images to R2 covered the products that got re-uploaded. Others still pointed at the old origin afterward. A migration that works on the instance you tested is not a migration.

4.9 Assets taken at face value

The one I’d have missed if I hadn’t gone looking at bytes.

All eight product images were 1.5–1.8MB PNGs at 1024×1024, carrying a .jpg extension. Not JPEGs. Nothing in the pipeline questioned it, because the filename asserted a format and every layer downstream believed the filename.

The consequence only appears under load: on a cache miss, Vercel’s image optimizer has to fetch and decode a 1.7MB PNG from origin before it can resize or re-encode anything. Warm requests were fine. Cold ones were expensive, which is exactly the profile that hides from casual testing and shows up as unexplained tail latency.

The general form: the agent trusts metadata about a thing over the thing itself. File extensions, declared types, names. When they disagree with reality, nothing checks.

5. The review process that catches this

None of the above argues against agent-driven implementation. The build moved far faster than hand-writing it would have. It argues that review has to be structured, because clicking around finds almost none of these.

Read the shipped payload, not the rendered page. Null ranks, metadata gaps, mixed image origins, price coverage — invisible in a browser, obvious in the server response.

Check coverage as a count, not by sampling. Nine variants × eight products × two currencies is 144 price entries. The multi-region failure mode is that prices seed for the default currency and silently not the others: the US store works perfectly, the EU store renders empty prices, and you find out when a customer switches regions.

Inspect the artifact, not its description. Section 4.9 exists because I eventually checked the actual bytes of an image rather than its filename. Extensions, declared types, and content-type headers are all claims. Verify the ones that matter.

Adversarial review by a second model. Handing deployed output to a different model with “find what’s wrong with this” surfaced several of these — the localhost metadata, the missing product OG tags, the banner sizing — faster than I would have. An agent reviewing its own work shares its own blind spots. A different one doesn’t.

Measure repeatedly, and read the distribution. Which is most of the next section.

6. Profiling: what the score didn’t tell me

The homepage scored 95 on mobile. Good number, easy route — no image gallery, no variant selector, no client-side option state. Publishing it would have been the convenient move.

The catalog page is the one customers browse. It came back 82.

Round 1 — the obvious blocking work

Baseline at /us/store, mobile: performance 82, FCP 930ms, LCP 2.4s, TTI 5.3s, TBT 608ms, CLS 0.

CLS at 0 meant layout was solid. FCP under a second meant the server response was fine. TBT — over half a second of blocked main thread, weighted at 30% of the composite — was the problem. A JavaScript execution problem, not a network or layout one.

Two causes, both agent defaults. The LCP image was lazy-loaded. And the category navigation was a client component in the layout receiving the entire category tree, with every product’s full serialized description embedded in its props, hydrating on every route — for a menu most visitors never open.

Result: 98, TBT 156ms, LCP 1.3s.

That looked like a finish. It wasn’t, for two reasons.

Round 2 — the measurement tool was the payload

TTI had barely moved: 5.3s to 5.2s, while TBT collapsed. Those normally track together. Blocking work had come off the main thread, but something was still keeping the page from settling.

The Lighthouse treemap answered it. Total JavaScript was 386.9 KiB, and Google Tag Manager’s gtag.js was 169.4 KiB of it — 44%, more than three times the largest first-party chunk. It loads late and asynchronously, so it never showed up in TBT, but it kept the network and main thread busy long after primary content was done.

I’d tried mitigating it first — deferring the script, then giving the demo its own GA4 property — before concluding that a demo storefront doesn’t need analytics at all.

Result: bundle 386.9 → 214.9 KiB, TTI 5.2s → 3.1s.

And the score went down, 98 to 96.

Round 3 — the number I’d published was a lucky run

A single Lighthouse run is a sample. I’d written 1.3s LCP into a draft on the strength of one of them. Three runs put the LCP median at 2.6s, with individual runs ranging 1.4s to 3.2s.

So the 1.3s was the outlier and 2.6s was the truth. The score hadn’t dropped because removing analytics hurt anything; it had dropped because the first number was never real.

An 1.8-second spread on one metric while FCP held within 46ms of itself points at a single remote resource with unpredictable fetch time. The LCP element: the first product image.

Round 4 — fixing delivery, and finding it wasn’t enough

Bound cdn.amberhour.coffee to the R2 bucket, off the rate-limited r2.dev development endpoint. Added priority to the first four grid items — index < 4 covers the full first row at every breakpoint, from two-column mobile to four-column desktop, rather than just the first pair.

Five-run median: performance 95, LCP 2.4s, TBT 192ms, CLS 0. Range 86–98.

Better, not fixed. Two of five runs still landed in the 80s, and LCP still had a 3.4s tail. Delivery wasn’t the bottleneck.

Round 5 — the source files

This is section 4.9. All eight product images were 1.5–1.8MB PNGs mislabeled .jpg. Every cache miss forced the optimizer to fetch and decode a ~1.7MB PNG from origin before it could produce anything.

Converted all eight to real WebP at quality 82: 60–100KB, roughly 95% smaller. Uploaded to R2, updated eight image.url and eight product.thumbnail rows, deleted the old PNGs, revalidated the storefront cache and confirmed the live page serves .webp sources. A cold-cache transform of the largest remaining source, 92KB, now takes 350ms.

Five-run median: performance 95, FCP 914ms, LCP 1.9s, TTI 3.1s, TBT 197ms, CLS 0.

What the median hid

The median score is 95. It was also 95 before this round. By that measure, converting the images did nothing.

The distribution says otherwise. The range moved from 86–98 to 90–98 — the floor rose four points. LCP median went 2.4s to 1.9s and the spread narrowed from 2.0s to 1.5s.

That’s what fixing tail latency looks like. The good runs were already good, so the median barely moves; the bad runs stop being bad. If I’d reported only the median I’d have concluded the fix was worthless, and if I’d reported only my best run I’d have claimed 98 and been wrong twice over.

The full arc

RoundChangeRunsPerfLCPTTITBTJS
Baseline822.4s5.3s608ms387 KiB
1Eager LCP image, trimmed nav payload1981.3s5.2s156ms387 KiB
2Removed GA41962.6s3.1s137ms215 KiB
4CDN domain, priority on first row5952.4s3.2s192ms215 KiB
5PNG → WebP, 95% smaller5951.9s3.1s197ms216 KiB

Final, /us/store, mobile: performance 95 (median of five, range 90–98), accessibility 100, best practices 96, SEO 92. FCP 914ms, LCP 1.9s, TTI 3.1s, TBT 197ms, CLS 0.

Live: https://www.amberhour.coffee · Source: github.com/msanbo/coffee-demo-store

7. What I’d do differently

Model roast level and process as tags from the start. The metadata shortcut works until someone asks for filtering, and in a category where people shop by roast profile, that day comes quickly.

Set option and variant ranks in the seed script rather than finding the ordering problem through the UI. Anything rendered in a specific order needs that order in the data.

Validate assets on upload. A check that a file’s actual format matches its extension, and that source images are under a size budget, would have caught section 4.9 before it ever reached production. Cheap to add, and it’s the class of bug an agent will reintroduce every time.

Measure five runs from the beginning. I wrote a single-run number into a draft and had to retract it. The tool tells you it’s an estimate; believe it.

8. Why this is the write-up worth reading

The interesting skill in agent-assisted development isn’t prompting. It’s knowing what to distrust. The agent produces something that runs, looks right, and carries defects that only appear in production, on mobile, in a second region, on a cold cache, or after a reseed. Every category above is predictable, which is what makes the list worth having.

It will also hand you a 95 on the easy route while the page your customers use sits at 82, and hand you a 1.3s LCP that turns out to be the best of five samples. Neither of those is the agent lying. They’re the agent answering exactly what was asked and nothing more, which is the whole job description of the person reviewing it.

If your team is building commerce this way — and most are, whether or not it’s in the process doc — the review layer is where the risk lives.

I build Next.js storefronts on Medusa. Multi-region, variant-heavy catalogs, checkout, performance.

mike@mikesanborn.dev