Web shop and headless CMS for a fireplace retailer
A shop for bio-ethanol, electric and garden fireplaces, built on a platform I already had: a SvelteKit front on Vercel, a Node API on App Engine, a schema-driven CMS, and Mollie for the money.
Multihaarden sells fireplaces from a showroom in Appingedam, in the north of the Netherlands: bio-ethanol burners that hang from the ceiling, electric inserts, fire tables for the garden, and the fuel and glass to go with them. A hanging Le Feu is €1930 and comes with four pole lengths. That is the catalogue, 462 products at the moment, most with a choice to make before they can go in the basket.
The shop went live on 28 October 2021 and I have been its only developer since. It runs on four repositories: the SvelteKit front, an API on Google App Engine, a CMS the shop staff edit in, and a small package with the field definitions that the API and the CMS both install. Some of the decisions below were made before Multihaarden existed.
A platform copied, a catalogue imported
The first commit in three of the four repositories, on the same day in May 2021, is a copy: copied ev-api, copy ev-cms, copy from ev-schema. Ev is Evenses, an entertainment booking site I had built the platform for, with suppliers who propose their own listings and a moderator who approves them. Multihaarden got that platform with the genres and occasions taken out and a basket put in. The seams still show: the typeahead route is called evenses_extra_serp, and the image resizer runs in the Evenses cloud project to this day.
The catalogue came out of a Shopify store. The export CSVs are still in the API repository beside the nine-hundred-line importer that turned them into products, variants and paths, converting the HTML descriptions to markdown on the way. The old Shopify addresses under /products/ were registered as extra paths, which is why they still answer 200 rather than 404 four years later.
Choosing a fireplace
A product has options, and the options make a matrix. The schema calls them variants: each has a name such as Lengte Sky pole and a list of values, and every combination of values gets its own row in variants_data with its own image, price, compare-at price, weight, stock count and SKU. The row is keyed by the option keys, so 140 cm on that pole is RVzVK:lIOCJ. On the product page the radio buttons pick a row, the price and the main image follow it, and the slideshow jumps to the picture that belongs to it. Swipe to a slide that belongs to a variant and the radio button moves too.
The first row of the matrix is copied onto the product itself when it is saved, so price, image and SKU are ordinary indexed MySQL columns and the category listing, the sitemap and the Google Shopping feed can be plain queries. The full matrix stays in a JSON column for the one page that needs all of it.
Filtering is done in the browser. Every page load includes the matrix: one string, 13 kB for the whole catalogue, one record per product with its id, price, rating and category ids separated by slashes and hashes. A category page takes the ids that match, fetches those products in one request, and sorts them client side, by price or by a relevance score over whichever filters are set. Moving the budget slider never touches the server.
The basket is a Svelte store persisted in localStorage, one entry per product-and-variant stamp. Shipping is free from €50 and €5.95 below it, a coupon can be a percentage or a fixed amount, and both rules run again on the server when the order is placed, because the client is not trusted with prices.
Paying
Checkout is one form: name, e-mail, phone, address, company name if any. Submitting it posts the basket and the coupons to the API, which resolves every stamp to its variant row, recomputes the total from the database, and creates a Mollie payment with the customer fields in the payment metadata and a redirect back to /bestelling/<token>. The customer pays through Mollie and lands on a thank-you page.
There is no orders table. The platform I copied did not have one, and I did not add one. An order is a row in customer_actions with a JSON bag holding the basket and the address, and the payment status is another row that the Mollie webhook appends. The thank-you page folds the last month of rows for that customer into one object, and if the webhook has not arrived yet it asks Mollie directly instead of waiting. The staff see a list with name, e-mail, Mollie id and payment status, and a drawer with the products. The order confirmation goes out through SendGrid, one mail to the shop as a table of every field, one to the customer from a markdown template the staff edit in the CMS.
What this cannot do is track fulfilment. There is no state after paid, no invoice, no carrier. The mail is the workflow.
One schema, two programs
mh-schema is a package with no dependencies and no build step. It is a list of field descriptors per entity: product, page, article, path, tree, the global settings, the customer, and the fixed pages. A descriptor says the column name, the type, which tab it belongs on, how to validate it, and for an image which formats to accept and what size to store. The CMS builds its forms from that list. The API walks the same list to decide what to JSON-encode, what to coerce to a number, which image to crop to what, and, on both sides, to delete every posted key the list does not name.
It is installed by git SHA, not by version number. A one-line deploy script stamps the current date into timestamp.js, pushes, and then runs npm install of that exact commit in the API and the CMS. The CMS sends the stamp as a header with every request, and the API refuses a stale one after a day of grace with a message telling the editor to reload. Adding coupons in November 2024 was a change in the schema, a validation route in the API and a form in the front, landed in all four repositories on one day; the CMS form came for free.
One field, read three times. The schema is the vocabulary the CMS and the API agree on; the walk that applies it is written twice, once in each, with the same semantics.
Where the milliseconds went
The front is server rendered on Vercel, and every response carries s-maxage=1, stale-while-revalidate. The edge therefore serves the page the previous visitor generated and rebuilds it behind them. The staff manual explains this to editors with a diagram and one instruction: refresh twice to see your change. Measured from here today, time to first byte is 20 ms on the home page and 22 ms on a product page. The API, asked directly for that product, answers in 175 ms; that is what the cache hides.
Behind the edge, the API runs on one warm App Engine instance, minimum one and maximum one, which is what makes its in-memory caches honest: globals for an hour, the matrix for ninety seconds, the sitemap for two minutes, each serving the previous value while the next is being built, and rebuilt a couple of seconds after a save in the CMS. The globals, the reviews and the matrix arrive in one response, the page in a second.
Images go into a Cloud Storage bucket once, cropped and converted to progressive JPEG by sharp at upload, gzipped, with a cache lifetime of ten years. The front asks for them through a resizing proxy on App Engine by encoding the size in the path, w:450,h:450,q:50, with a 2x variant in srcset. That proxy replaced imgix, which the shop had used since launch; the three-repository commit that did it is called imgs. The site’s own HTML, CSS and JavaScript come to 165 kB on the wire for a product page; largest contentful paint is under 400 ms on desktop and about 1.2 s on an emulated phone.
The home page is the honest exception. Its four category tiles render the uploaded file as is, without the proxy, and one of them is a 2.2 MB PNG that a member of staff exported from a design tool. The pipeline resizes what it knows about; that tile is not in the list, so the home page transfers 3.7 MB where the product page transfers 1.2 MB, and most of the rest on both is Google Tag Manager, the Facebook pixel and the chat widget.
The pieces and how they talk. The shopper never reaches the API directly and the CMS never reaches the shop; the API sits between them with the one instance whose memory is the cache.
The staff side
Editors log in at a static React Admin site and get a pencil. Logging in sets a cookie on the shop’s domain, and the shop, seeing it, draws a small edit button on every page that opens the CMS on that page’s form. Paths are their own table: an entity can have several, the first is canonical, and the manual tells editors never to delete one but to add the new one and make it primary, which is how the Shopify addresses survived.
Every save writes a diff to a revision table, and a form has a history tab that loads any earlier state back into the fields. The supplier role from Evenses is still there: a supplier’s save becomes a suggestion, the moderators get a mail, a hashed /concept/ URL on the live site shows the product as it would look, and the revision screen has approve and reject where save and delete would be. Multihaarden has a supplier feed rather than supplier logins: a route pulls an electric-fireplace wholesaler’s XML, converts the descriptions to markdown, mirrors the images into the bucket, and upserts by feed id. It only deletes what has disappeared when the feed holds more than twenty items and fewer than a hundred synced products exist, so an empty feed cannot empty the shop.
Search is the shop’s own. Titles are indexed as metaphone codes, a query is phoneticised the same way and re-ranked by Levenshtein distance, so haart finds haard. Every query is counted in a table the staff read sorted by frequency. The Google Shopping and Facebook feeds are built from the same product rows, with the first variant’s price and the resized main image.
The CMS has not needed a change since coupons went in. Thirty-five Google reviews sit in the footer, averaging 9.8.