---
title: "Een diamond-painting-webshop die de code niet kon redden"
date: 2026-04-26T04:19:00.000Z
updated: 2026-04-28T09:05:40.254Z
canonical: https://ninokroesen.nl/blog/een-diamond-painting-webshop-die-de-code-niet-kon-redden
lang: nl
author: "Nino Kroesen"
---

# Een diamond-painting-webshop die de code niet kon redden

Begin 2026 lanceerde ik een custom diamond-painting-webshop op mijndiamondpainting.nl, deed één verkoop en sloot de webshop binnen een maand weer. De reden dat het ding doodging zat nergens in de code.

De leverancier zat in China, het product was gepersonaliseerd (de klant uploadt een foto, wij printen die op een genummerd canvas dat ze daarna met de hand met diamantjes invullen) en er was geen versie van die combinatie waarin ik de order snel genoeg op een Nederlandse deurmat kreeg om de ene klant die ik had tevreden te stellen, zonder dat de verzendkosten de toch al dunne marge op een instapprijs van €22,95 op zouden vreten. De leverancier was ook niet bepaald goedkoop, en — dat kwam ik pas achter toen de webshop al live was — ze hadden gelogen over de verzendkosten waarop ik die instapprijs had gebaseerd. Ik had geprijsd tegen een fictie. Bij de echte cijfers kwam de rekensom niet uit, en dat ging ook nooit gebeuren.

Alles boven de supply chain was een tractable engineering-probleem. Het meeste daarvan heb ik gebouwd. De leverancier was niet tractable, en de leverancier was het enige onderdeel dat echt moest werken.

## De vorm van het ding

Een server-rendered Node.js-e-commerce-app, volledig Nederlandstalig. Express, EJS, Tailwind, Mongoose op één MongoDB, vanilla JS op de client met CDN-loaded Cropper.js voor het croppen en Chart.js voor de admin-dashboards. De topologie op de bak was Nginx voor TLS en static, Varnish voor Node voor page caching, PM2 met twee "frontend"-cluster-mode-workers voor publiek verkeer en een aparte "backend"-fork-process die de admin-UI en zes cron jobs droeg. Eén Mongo, één disk, één Varnish, één Nginx — maar met een role flag op elk proces zodat admin-routes en jobs niet op de publieke workers gemount werden.

De order-funnel was een state machine die in de server-side sessie van de gebruiker leefde, doorlopen via zes URLs: foto-upload, maatkeuze, een optionele AI-preview, cart, klantgegevens, payment service provider, bevestiging. Een middleware checkte op elke GET de cart-shape en redirectte je door naar de vroegste ontbrekende vereiste. Een tweede middleware migreerde legacy sessie-shapes zodat een een-jaar-oude verlaten sessie nog steeds kon hervatten waar hij gebleven was. Zes background jobs deden abandoned-cart-reminders, image cleanup, social-media scheduling, een AI-optimizer waar ik nog op terugkom, een bot-behaviour-detector en disk-space-monitoring. Eén gedeeld settings-document hield de complete business-config — maten per aspect ratio, quantity-discount-tiers, promotie-regels, engagement-tier-definities, de limieten van de AI-optimizer. Admin redigeerde het; elk ander subsysteem las eruit.

Het was, voor wat het was, een serieus complete webshop.

## Het ding waar ik niet omheen kon programmeren

Ik koos de niche uit een eerdere onderzoekspipeline die ik precies hiervoor had gebouwd — diezelfde die ik apart heb opgeschreven als de [niche-hunt-postmortem](/blog/een-research-pipeline-voor-wat-het-internet-wel-laat-zien). De V1-output van de pipeline zag er verdedigbaar uit: meetbaar zoekvolume, een categorie waar het Nederlandse online aanbod dunner was dan verwacht, een vorm waarin een solo operator met een zelfgebouwde stack een kans had. De pipeline deed zijn werk binnen zijn eigen kaders. Wat hij niet kon zien, was de operationele laag onder de categorie, want daar zat het probleem, en dat begreep ik pas nadat ik daadwerkelijk gelanceerd had.

Het concrete probleem: gepersonaliseerde producten die op aanvraag in het buitenland gemaakt worden. Elke order betekende een foto die in Nederland geüpload werd, omgezet werd naar een genummerd patroon, in China op een canvas geprint en weer terug verscheept. Zelfs in de meest optimistische lane die de leverancier kon quoten was het van deur tot deur weken, geen dagen — lang genoeg dat mijn eerste en enige klant al klaagde over de bezorging voordat iets anders in de flow überhaupt de kans kreeg om kapot te gaan. Elke verzendupgrade die er echt toe deed — betalen voor echte airfreight — vrat de marge op bij een instapprijs van €22,95, waar "marge" al de markup van de leverancier zelf, de fee van de payment processor, BTW en de vaste productiekost per canvas omvatte. De leverancier was om te beginnen niet bijzonder goedkoop; ze waren een van de professionelere opties die ik bekeken had, wat de nette manier is om te zeggen dat de bodem op de unitprijs gezet werd door de goedkoopste optie waarvan ik nog vertrouwde dat ze iets zouden afleveren dat een Nederlandse klant niet meteen retour zou sturen. Onder dat alles zat dan nog de communicatie-frictie van werken met een leverancier aan de andere kant van de wereld. "Kun je bevestigen dat het patroon correct geprint is voordat het verstuurd wordt" was geen vraag waar ik altijd binnen dezelfde week een schoon antwoord op kreeg. De leugen over de verzendkosten was de duidelijkste instantie van datzelfde patroon. Het bedrag dat ze hadden gequoot en waarop ik de retailprijs gebaseerd had, was niet het bedrag dat de leverancier in werkelijkheid in rekening bracht zodra er echte orders gingen lopen, en tegen de tijd dat ik dat doorhad was de retailprijs al live en ankerde hij een hele prijsladder eronder. Een live webshop in prijs naar boven bijstellen is geen zet waarmee je een klein bedrijf herstelt van een slechte uitgangspositie. Het is een zet waarmee je je vroege klanten vertelt dat ze een testronde waren.

En de Nederlandse marktverwachting is geen weken. De Nederlandse marktverwachting wordt gezet door bol.com en de baseline van PostNL-next-day op binnenlandse orders. Die mail over de bezorging kwam binnen weken voordat het product er was, en daar eindigt dat verhaal al. De klantenservice schaalde mee met het aantal orders, en "schaalt mee met het aantal orders" is de verkeerde richting voor een klantenservicekanaal waarvan de taak is de klant uit te leggen dat zijn gepersonaliseerde canvas op een trage boot zit.

Er waren de voor de hand liggende workarounds die ik overwogen heb. Een Nederlandse of Europese leverancier met een magazijn in de EU had de transittijd dramatisch verkort, maar de leveranciers die ik kon vinden printten óf geen gepersonaliseerde canvases óf wilden een unitprijs waardoor de retailprijs in een range terechtkwam waar de categorie niet meer verkoopt. Voorraad aanhouden was geen werkbare optie voor een gepersonaliseerd product — het hele punt is de eigen foto van de klant. Vooruitbetalen voor blanco canvases in bulk en de printstap lokaal doen was een fabrieksproject, geen webshop-project, en dat ging niet binnen hetzelfde kwartaal op het budget van een solo operator gebeuren.

Geen van die workarounds is iets dat code kan oplossen. Het zijn leverancierskeuze- en unit-economics-problemen, en de enige data die ik had die iets waard was, kwam uit het daadwerkelijk runnen van de webshop en kijken waar de eigenlijke dag heen ging. Dat stuk heeft me ruwweg een maand kalendertijd gekost en één canvas dat naar één klant verscheept is — goedkoop schoolgeld, voor wat je eruit leert.

## Tradeoffs die goed waren om de verkeerde redenen

Veel van de project-beslissingen die destijds goed leken, zien er los van elkaar nog steeds goed uit. Pas als je ze naast de supply-chain-realiteit zet, worden het "right tool, wrong problem".

### Alles zelf bouwen

Ik bouwde de webshop vanaf nul op Node in plaats van Shopify plus een configurator-app te pakken. Op de schaal van een solo operator, waar de kosten serverhuur en mijn eigen tijd waren, was dat op zichzelf redelijk. Auth is zes regels middleware die een sessie-boolean checken, gezet door een plaintext-vergelijking tegen een gebruikersnaam en wachtwoord die rechtstreeks uit env vars komen. De factuurgenerator is PDFKit in-process. Abandoned-cart-recovery is een cron job en vier EJS-templates. De zelfgebouwde versie leverde me ook het ene stuk van de funnel waar ik nog steeds van overtuigd ben dat het een echt categorievoordeel was — een live in-flow preview die de geüploade foto van de klant pakte, de pixellation- en palette-mapping-stap deed die het geprinte patroon zou worden, en hun liet zien wat ze daadwerkelijk kochten voordat ze betaalden. Geen van de configurator-apps die ik bekeken had, had me dat laten doen zonder de klant op een embedded iframe van iemand anders zijn tool te dumpen, wat het zicht op de sessie en de abandoned-cart-resume kapot zou hebben gemaakt. Niets in de rest van de stack is best-in-class, maar niets stuurt me een maandelijkse factuur om het te draaien, het hele control surface past in één repo die ik van boven tot beneden kan lezen, en de ene feature die voor de klant het meest uitmaakte was iets dat de zelfgebouwde versie kon shippen en de platformversie niet.

Wat die beslissing me gekost heeft, in de context van wat het project uiteindelijk doodde, is dat ik het tijdsbudget dat naar het upstream-probleem had kunnen gaan — een betere leverancier vinden, of eerder doorhebben dat de categorie er geen had — heb besteed aan het bouwen van een stack die downstream van een kapotte leverancier zat. De twee grootste controllers in de repo waren elk over de tweeduizend regels gegroeid. De codebase is niet klein. Niets ervan maakte de leverancier sneller. De eerlijke splitsing is dat de klantgerichte helft — de funnel, de live preview, de cart, de order flow — zijn waarde op zijn eigen merites verdiende, en dat stuk zou ik op dezelfde manier opnieuw bouwen. Wat ik overbouwd heb was de back office: een AI-prijsoptimizer voor verkeer dat ik niet had, een settings-document dat engagement-tiers en een promotie-ladder modelleerde voor nul betrokken klanten, een bot-behaviour-detector die wachtte op adversarial verkeer dat nooit kwam, een social-publishing-scheduler die posts in de wachtrij zette voor een publiek dat niet bestond. Niets daarvan was nodig om het idee te valideren. Een kale admin waarmee ik producten kon aanmaken, orders kon afhandelen en een lijst kon bekijken, had dezelfde vraag beantwoord — komt de supply chain uit op de prijs die de markt accepteert — voor een fractie van de kalendertijd. Voor een business waar de supply chain de bottleneck is, is het juiste antwoord om de minimale klantgerichte versie te shippen en de back office van een echte webshop pas te bouwen als de webshop er een verdiend heeft.

### Claude aan een leiband

Het interessantste stuk codebase, en het stuk dat ik in een ander project zou meenemen, is een AI-optimizer die op zijn eigen gezag naar het productie-settings-document mocht schrijven en promoties aan mocht maken — binnen een kader. Het kader zat in code, niet in prompts. Elke numerieke output die Claude produceerde, werd geclamped tegen expliciete limieten: een plafond op het engagement-discount-percentage, een plafond op het promotion-discount-percentage, een cap op hoeveel promoties tegelijk actief mochten zijn, een cap op hoe lang één promotie mocht lopen. Daarbovenop zat een minimum-tijd-tussen-veranderingen-cooldown, en de applier hield een md5-hash bij van elke toegepaste tier-config en weigerde een config opnieuw toe te passen die hij in de laatste drie runs al gezien had, omdat de eerste versie van het ding op elke cyclus tussen tiers heen-en-weer flipte. De anti-oscillatiecheck was de lelijkste zes regels in de repo, en de meest dragende.

De houding, op papier: de prompt is een suggestie; de code is het contract. De system prompt vertelde het model "verander niets dat te recent is". De applier ging er vanuit dat hij het toch zou proberen, en checkte de timestamps zelf. Dat is de leiband, en dat is het patroon dat ik overal opnieuw zou shippen waar ik een model productie-state laat aanraken.

De eerlijke beperking aan dit verhaal: ik heb de optimizer nooit in auto-apply mode gedraaid tegen echt klantverkeer op een volume dat het waard was om over te praten. De leiband was gebouwd. Het ding waar de leiband voor was, kwam nooit opdagen. In een ander project, met een echte bottleneck waar een LLM daadwerkelijk bij kan helpen, zou ik exact dit patroon shippen — harde caps, een cooldown, idempotency by hash, het uitgangspunt dat de prompt adviserend is en de clamps niet. Maar ik kan je niet vertellen "kijk hoe het me in productie gered heeft." Ik kan je alleen vertellen dat het patroon generaliseert, en het is het ene stuk van deze webshop dat ik in het volgende ding ga liften dat een model state mag laten aanraken.

### Checkout als server-side sessie

De andere beslissing die ik zonder voorbehoud zou herhalen, is de hele checkout server-rendered houden met de cart binnen de server-side sessie van de gebruiker. Geen cart-API, geen client-side cart-store, geen hydration-bundle die naar de browser gestuurd wordt. Omdat de sessie de waarheid is, was een stapel features die op een React-stack elk een project op zich zouden zijn geweest, allemaal vijf-regelige edits op één shape. Cart sharing — kopieer een link, de andere persoon landt in jouw cart met het item geconfigureerd — is een shared-cart-document dat aan de ontvangerskant terug-deserialiseert in een sessie. Abandoned-cart-recovery is een JWT-magic-link in een mail die de sessie opnieuw plant en de bezoeker twee stappen verder dropt dan waar hij gebleven was. Re-cropping ter plekke op een al-toegevoegd item is het item bewerken in de cart-array van de sessie en redirecten. Elke "feature" die ik bovenop een cart-API op een SPA had moeten bouwen, was een trivialiteit van een edit op de sessie-shape. Dat patroon — de server-side sessie is de app-state, en multi-step-flows horen daar te leven — neem ik mee naar elk toekomstig project met een hervatbare of deelbare multi-step-flow, e-commerce of niet.

## Eerlijke voorbehouden

Er zijn geen geautomatiseerde tests. Niet voor de prijsberekening, niet voor de webhook van de payment service provider, niet voor de abandoned-cart-scheduler, niet voor de apply-stap van de AI-optimizer. De cart-prijsberekening-functie is één procedure van 170 regels die per-maat-promoties, quantity-tiers, sitewide promoties, engagement-tiers, verzending en een BTW-reverse-charge-tak op elkaar stapelt, en er staat geen enkele assertion in de hele repo die ook maar iets daarvan checkt. De vervanging was dat ik vóór het live gaan met de hand door de funnel liep voor een handvol scenario's — verschillende maten, promotie-combinaties, abandoned-cart-resumes, BTW-aan- en BTW-uit-paden. De redenering destijds was dat zolang de webshop nog überhaupt geen verkoop gedaan had, een volledige Playwright- of Puppeteer-suite over de hele flow moeilijk te verdedigen was tegen de heel reële kans dat de webshop nooit een tweede order zou zien. Gegeven hoe die weddenschap is uitgedraaid was de keuze in elk geval intern consistent. Het is geen patroon dat ik als algemeen principe zou verdedigen, en bij een rewrite — of op het moment dat het volume het zou rechtvaardigen — is het het eerste dat ik zou toevoegen.

Admin-auth is de plaintext-env-var-vergelijking die ik hierboven beschreef. Prima voor één persoon, gênant in een verslag. Geen 2FA, geen audit trail, geen rotatie-verhaal anders dan SSH'en naar de bak en het env-bestand met de hand bewerken.

De "uitkomstmeting" van de AI-optimizer leest visit- en order-aantallen vanaf de timestamp van de run en stempelt before/after op het run-record. Geen holdout group, geen statistische test, geen correctie voor dag-van-de-week of voor veranderingen in advertentiebudget. Als ik dat in een post "data-driven" zou noemen, zou het woord "data" nogal wat te tillen hebben.

De meeste features op de webshop — de concurrent-scraper die op Puppeteer draait, de social-publishing-scheduler, de AI-image-generation-admintool, de optimizer zelf — hebben nooit adversarial verkeer gezien, want adversarial verkeer kwam nooit. Eén verkoop is geen load test. Ik kan je vertellen dat deze systemen op happy-path-dev werken. Ik kan je niet vertellen hoe ze zich gedragen onder welke versie van verkeer dan ook die ze interessant zou maken.

De pre-live-checklist had bij shutdown nog niet-aangevinkte items: HSTS-header, een Varnish-HIT-verificatieronde, een rate-limiter-stress-test, een 401-probe op de niet-geauthenticeerde admin-routes. Ik heb de webshop met die gaten gedraaid op de gok dat het verkeersvolume ze niet zou vinden voor ik de kans had om ze te dichten. Ik kwam ermee weg omdat het volume nooit kwam, wat niet hetzelfde is als dat de gok klopte.

En — dit is het ding dat ik eerder had moeten zien — de laatste week aan commits voordat de webshop stilviel zijn allemaal mobile-overflow-fixes en een "Vanaf €22,95"-pill op de homepage hero, voortgekomen uit een CRO-onderzoeksdocument over waar op de pagina je de prijs moet tonen voor een conversielift op een landingspagina. Die volgorde was precies omgekeerd. Ik was het stuk van de funnel aan het polijsten dat al werkte, omdat dat stuk binnen mijn controle lag, in plaats van te duwen op het stuk dat dat niet was.

## Waar het nu staat

Dicht. De repo is naar een archief-directory op disk verplaatst en er is geen plan om hem weer open te trekken. Ik heb hem niet verwijderd, omdat de code zelf herbruikbaar is — specifiek de checkout-state-machine, het cart-as-session-patroon en de LLM-on-a-leash-optimizer — en ik die liever in het volgende ding lift dan ze vanaf nul te herschrijven.

De beslissing om hem niet weer open te trekken gaat eigenlijk niet over de code. Het gaat erover dat ik een maand de winkel heb gerund en heb gekeken waar mijn dagen daadwerkelijk heen gingen. De meeste uren waren geen ontwikkeluren, het waren klantenservice-uren over verzending, het managen van de verwachtingen van een klant wiens order op een boot zat. Dat is de baan waar het runnen van deze webshop op enig betekenisvol orderniveau op uit zou komen, en die baan wil ik niet. De [nicheselectie-pipeline die ik apart heb opgeschreven](/blog/een-research-pipeline-voor-wat-het-internet-wel-laat-zien), had me naar een andere niche kunnen wijzen en de code die ik hier geschreven heb, had daarop aangepast kunnen worden; wat niet aan te passen is, is dat ik nu weet dat ik niet op de stoel van de e-commerce-operator wil zitten. Dat is een goedkopere les om uit een maand te halen dan uit zes, en dat de webshop zijn waarde daarmee heeft verdiend — als het ding dat me dat geleerd heeft — is waarschijnlijk de eerlijke opbrengst van het project.

Als je een D2C-webshop voor een gepersonaliseerd product aan het bouwen bent op een leverancier met lange lead times en de rekensom op de instapprijs duidelijk niet uitkomt — pauzeer voordat je een maand aan de frontend besteedt. De frontend is niet de bottleneck. De frontend is nooit de bottleneck op een webshop waar de supply chain een probleem van deze vorm heeft. De goedkoopste versie van het experiment is een gesprek met de leverancier, een ruwe verzendquote en een eerlijke blik op of de unit economics overleven op de prijs die de markt daadwerkelijk accepteert. Doen ze dat niet, dan gaan geen "Vanaf €22,95"-pills je redden.

## Wat ik meeneem naar het volgende project

Het leiband-patroon — harde caps, een cooldown, idempotency by hash, in code in plaats van in de prompt — voor elk toekomstig systeem dat een LLM productie-state laat aanraken. Dat is het ene stuk van de webshop waarvan ik het meest overtuigd ben dat het meereist.

De server-side sessie als app-state — voor elke multi-step-flow die hervatbaarheid, delen of recovery nodig heeft. Het feit dat de sessie autoritatief is, verandert een stapel "features" in one-liner-edits op de shape, en hoe langer ik aan webprojecten werk, hoe vaker ik mezelf betrap op een grijp naar een client-side store waar een server-side sessie de hele klus al gedaan zou hebben.

De volgorde van de checklist — supply chain eerst, frontend laatst — op alles wat eigenlijk een fysiek-product-business is verkleed als webproject. De code is bijna nooit het ding. De code was hier niet het ding, en ik heb een maand gedaan alsof.
