By · ·

A diamond painting shop the code could not save

A diamond painting shop the code could not save

I launched a custom diamond painting webshop on mijndiamondpainting.nl in early 2026, made one sale, and shut the shop down inside a month. The reason it died was not anywhere in the code.

The supplier was in China, the product was personalized (the customer uploads a photo, we print it on a numbered canvas they then thread with diamonds by hand), and there was no version of that combination where I could get the order on a Dutch doorstep fast enough to satisfy the one customer I had, without the shipping cost eating what was already a thin margin at a €22.95 entry price. The supplier was not particularly cheap either, and — I only found this out after the shop was live — they had lied to me about the shipping costs I had used to set that entry price in the first place. I had been pricing against a fiction. The math did not close at the real numbers, and it was never going to.

Everything above the supply chain was a tractable engineering problem. I built most of it. The supplier was not tractable, and the supplier was the only part that actually needed to work.

The shape of the thing

A server-rendered Node.js e-commerce app, Dutch-language throughout. Express, EJS, Tailwind, Mongoose on top of a single MongoDB, vanilla JS on the client with CDN-loaded Cropper.js for cropping and Chart.js for the admin dashboards. Topology on the box was Nginx for TLS and static, Varnish in front of Node for page caching, PM2 running two “frontend” cluster-mode workers for public traffic and a separate “backend” fork process that carried the admin UI and six cron jobs. One Mongo, one disk, one Varnish, one Nginx — but with a role flag on each process so admin routes and jobs did not mount on the public workers.

The order funnel was a state machine living in the user’s server session, walked through six URLs: photo upload, size selection, an optional AI preview, cart, customer details, payment service provider, confirmation. A middleware checked cart shape on every GET and redirected you to the earliest missing prerequisite. A second middleware migrated legacy session shapes so a year-old abandoned session could still pick up where it left off. Six background jobs handled abandoned-cart reminders, image cleanup, social-media scheduling, an AI optimizer that I will come back to, a bot-behaviour detector, and disk-space monitoring. A single shared settings document held the entire business config — sizes per aspect ratio, quantity discount tiers, promotion rules, engagement-tier definitions, the AI optimizer’s limits. Admin edited it; every other subsystem read it.

It was, for what it was, a genuinely complete shop.

The thing I could not code around

I picked the niche off an earlier research pipeline I had built for exactly this purpose — the one I wrote up separately as the niche-hunt post-mortem. The pipeline’s V1 output looked defensible: measurable search volume, a category where the online Dutch supply was thinner than expected, a shape where a solo operator with a custom-built stack had a chance. The pipeline did its job on its own terms. What it had no way to see was the operational layer underneath the category, which is where the problem lived, and which I only understood after I had actually launched.

The specific problem: personalized products manufactured abroad on demand. Every order meant a photo uploaded in the Netherlands, converted to a numbered pattern, printed on a canvas in China, and shipped back. Even on the most optimistic lane the supplier could quote, door-to-door was weeks, not days — long enough that my first and only customer was complaining about delivery before anything else in the flow had a chance to break. Any shipping upgrade that actually mattered — paying for proper air freight — ate the margin at a €22.95 entry price, where “margin” already included the supplier’s own markup, the payment processor’s fee, BTW, and the fixed per-canvas production cost. The supplier was not particularly cheap to begin with; they were one of the more professional of the options I had looked at, which is the polite way of saying the floor on unit cost was set by the cheapest option I trusted to deliver something a Dutch customer would not immediately return. Sitting under all of that was the communication friction of working with a supplier on the other side of the world. “Can you confirm the pattern was printed correctly before it ships” was not a question I could always get a clean answer to inside the same week. The shipping-cost lie was the clearest instance of the same pattern. The quoted number I had built the retail price around was not the number the supplier was actually charging once real orders moved, and by the time I caught it the retail price was already live and anchoring a whole pricing ladder underneath it. Repricing a live shop upward is not a move that recovers a small business from a bad starting point. It is a move that tells your early customers they were a test run.

And the Dutch market expectation is not weeks. The Dutch market expectation is set by bol.com and the baseline of PostNL-next-day on domestic orders. That email about delivery arrived weeks before the product did, which is already the end of that story. The support load scaled with order count, and “scales with order count” is the wrong direction for a support channel whose job is to explain to the customer that their personalized canvas is on a slow boat.

There were the obvious workarounds I considered. A Dutch or European supplier with a warehouse in the EU would have cut transit time dramatically, but the ones I could find were either not actually printing personalized canvases or wanted a unit price that put the retail price into a range where the category no longer sells. Holding inventory was not a workable option for a personalized product — the whole point is the customer’s own photo. Pre-paying for bulk blank canvases and doing the print step locally was a factory project, not a webshop project, and it was not going to happen on a solo operator’s budget inside the same quarter.

None of those workarounds are something code can fix. They are supplier-selection and unit-economics problems, and the only data I had that was worth anything was from running the actual shop and watching where the actual day went. That piece cost me roughly a month of calendar time and one canvas shipped to one customer, which is cheap as tuition goes.

The tradeoffs that were right for the wrong reasons

A lot of the project decisions that looked good at the time still look good in isolation. It is only when you put them next to the supply-chain reality that they turn into “right tool, wrong problem.”

Building everything in-house

I built the shop from scratch on Node rather than taking Shopify plus a configurator app. At a solo operator’s scale, where the costs were server rent and my own time, this was reasonable on its own terms. Auth is six lines of middleware checking a session boolean, set by a plaintext compare against a username and password pulled straight from env vars. The invoice generator is PDFKit in-process. Abandoned-cart recovery is a cron job and four EJS templates. The custom build also bought me the one piece of the funnel I am still convinced was a genuine category advantage — a live in-flow preview that took the customer’s uploaded photo, ran the pixellation and palette-mapping step that would become the printed pattern, and showed them what they were actually buying before they paid. None of the configurator apps I looked at would have let me do that without dropping the customer onto an embedded iframe of someone else’s tool, which would have broken line of sight on the session and the abandoned-cart resume. Nothing in the rest of the stack is best-in-class, but nothing sends me a monthly invoice to run it, the whole control surface fits in one repo I can read top to bottom, and the one feature that mattered most on the customer side was something the in-house version could ship and the platform version could not.

What that decision cost me, in the context of what actually killed the project, is that I spent the budget of time that could have gone into the upstream problem — finding a better supplier, or realising earlier that the category did not have one — on building a stack that was downstream of a broken supplier. The two biggest controllers in the repo had grown past two thousand lines each. The codebase is not small. None of it made the supplier faster. The honest split is that the customer-facing half — the funnel, the live preview, the cart, the order flow — earned its keep on its own terms, and I would build that piece the same way again. What I overbuilt was the back office: an AI price optimizer for traffic I did not have, a settings doc modelling engagement tiers and a promotion ladder for zero engaged customers, a bot-behaviour detector watching for adversarial traffic that never showed up, a social-publishing scheduler queuing posts for an audience that did not exist. None of that was necessary to validate the idea. A bare admin that let me create products, fulfil orders, and look at a list of them would have answered the same question — does the supply chain close at the price the market accepts — at a fraction of the calendar time. For a business where the supply chain is the bottleneck, the right answer is to ship the customer-facing minimum and stop short of building the back office of a real shop until the shop has earned one.

Putting Claude behind a leash

The most interesting piece of the codebase, and the one I would carry into another project, is an AI optimizer that was allowed to write to the production settings document and create promotions on its own authority — inside a box. The box was built in code, not in prompts. Every numeric output Claude produced was clamped against explicit limits: a ceiling on the engagement-discount percent, a ceiling on the promotion-discount percent, a cap on how many promotions could be active at once, a cap on how long any one promotion could run. On top of that there was a minimum-time-between-changes cooldown, and the applier kept an md5 hash of each applied tier config and refused to re-apply a config it had seen inside the last three runs, because the first version of the thing flipped tiers back and forth on every cycle. The anti-oscillation check was the ugliest six lines in the repo, and the most load-bearing.

The posture, written down: the prompt is a suggestion; the code is the contract. The system prompt told the model “don’t change anything too recent.” The applier assumed it would try anyway, and checked the timestamps itself. That is the leash, and it is the pattern I would ship again anywhere I let a model touch production state.

The honest limitation on this story: I never ran the optimizer in auto-apply mode against real customer traffic at any volume worth talking about. The leash was built. The thing the leash was for never showed up. In another project, with a real bottleneck that an LLM could actually help with, I would ship exactly this pattern — hard caps, a cooldown, idempotency by hash, the assumption that the prompt is advisory and the clamps are not. But I cannot tell you “here is how it saved me in production.” I can only tell you the pattern generalises, and it is the one piece of this shop I will lift into the next thing that lets a model touch state.

Checkout as a server session

The other decision I would repeat without qualification is keeping the whole checkout server-rendered with the cart living inside the user’s server session. No cart API, no client-side cart store, no hydration bundle shipped to the browser. Because the session is the truth, a pile of features that would each have been a project on a React stack were all five-line edits on one shape. Cart sharing — copy a link, the other person lands in your cart with the item configured — is a shared-cart document that deserializes back into a session on the recipient’s side. Abandoned-cart recovery is a JWT magic link in an email that replants the session and drops the visitor two steps ahead of where they left. Re-crop-in-place on an already-added item is editing the item in the session’s cart array and redirecting. Every “feature” I would have built on top of a cart API on a SPA was a trivial edit on the session shape. That pattern — the server session is the app state, and multi-step flows should live there — I would take into any future project with a resumable or shareable multi-step flow, e-commerce or not.

Honest caveats

There are no automated tests. Not for pricing, not for the payment service provider webhook, not for the abandoned-cart scheduler, not for the AI optimizer’s apply step. The cart pricing function is one 170-line procedure stacking per-size promotions, quantity tiers, sitewide promotions, engagement tiers, shipping, and a BTW reverse-charge branch, and there is not a single assertion in the entire repo checking any of it. The substitute was me walking the funnel by hand through a handful of scenarios before going live — different sizes, promotion combinations, abandoned-cart resumes, BTW-on and BTW-off paths. The reasoning at the time was that until the shop had made a sale at all, a full Playwright or Puppeteer suite over the whole flow was hard to justify against the very real chance the shop would never see a second order. Given how that bet resolved, the call was at least internally consistent. It is not a pattern I would defend as a general case, and on a rewrite — or the moment volume justified it — it is the first thing I would add.

Admin auth is the plaintext env-var compare described above. Fine for one person, embarrassing in a writeup. No 2FA, no audit trail, no rotation story short of SSH-ing to the box and editing the environment file by hand.

The AI optimizer’s “outcome measurement” reads visit and order counts from the run timestamp forward and stamps before/after on the run record. No holdout group, no statistical test, no adjustment for day-of-week or ad-spend changes. If I called that “data-driven” in a post, the word “data” would be doing a lot of work.

Most of the features on the shop — the competitor scraper running Puppeteer, the social-publishing scheduler, the AI image-generation admin tool, the optimizer itself — never saw adversarial traffic, because adversarial traffic never showed up. One sale is not a load test. I can tell you these systems work on happy-path dev. I cannot tell you how they behave under any version of traffic that would make them interesting.

The pre-live checklist had unchecked items at shutdown: HSTS header, a Varnish HIT verification pass, a rate-limiter stress test, a 401 probe on the unauthenticated admin routes. I ran the shop with those gaps, on the bet that traffic volume would not find them before I had a chance to close them. I got away with it because the volume never arrived, which is not the same thing as the bet being correct.

And — this is the one I should have spotted earlier — the last week of commits before the shop went silent are all mobile-overflow fixes and a “Vanaf €22,95” pill on the homepage hero, born out of a CRO research document about where on the page to show price for a landing-page conversion lift. That order of operations was exactly backwards. I was polishing the part of the funnel that was already working, because that part was under my control, instead of pushing on the part that wasn’t.

Where it sits now

Shut down. The repo has been moved into an archive directory on disk and there is no plan to reopen it. I have not deleted it, because the code itself is reusable — specifically the checkout state machine, the cart-as-session pattern, and the LLM-on-a-leash optimizer — and I would rather lift those into whatever the next thing is than rewrite them from scratch.

The decision to not reopen it is not really about the code. It is about having run the store for a month and watching where my days actually went. Most of the hours were not development hours, they were shipping-support hours, managing the expectations of a customer whose order was sitting on a boat. That is the job running this shop at any meaningful order volume would have been, and I do not want that job. The niche-selection pipeline I wrote up separately could have pointed me at a different niche and the code I wrote here could have been adapted to it; the thing that is not adaptable is that I now know I do not want to be in the e-commerce operator’s seat. That is a cheaper lesson to come out of a month than out of six, and the shop earning its keep as the thing that taught me that is probably the honest value of the project.

If you are building a personalized-product D2C shop out of a supplier with long lead times and the math at the entry price does not obviously close — pause before you spend a month on the frontend. The frontend is not the blocker. The frontend is never the blocker on a shop where the supply chain has a problem this shape. The cheapest version of the experiment is a supplier conversation, a rough shipping quote, and an honest look at whether the unit economics survive at the price the market will actually accept. If they don’t, no amount of “Vanaf €22,95” pill badges is going to save you.

What I would carry into the next project

The leash pattern — hard caps, a cooldown, idempotency by hash, kept in code rather than in the prompt — for any future system that lets an LLM touch production state. That is the one piece of the shop I am most confident travels.

The server session as the app state — for any multi-step flow that needs resumability, sharing, or recovery. The fact that the session is authoritative turns a pile of “features” into one-liner edits on the shape, and the longer I spend on web projects the more often I catch myself reaching for a client-side store when a server session would have done the whole job.

The order of the checklist — supply chain first, frontend last — on anything that is actually a physical-goods business dressed as a web project. The code is almost never the thing. The code was not the thing here, and I spent a month ignoring that.

Related reading