Some details are redacted. With an access key, you can see the full version.
A renewable-energy and construction equipment rental company
Rebuilding a rental company site and its CMS
Replaced a template-bound Wix site with a purpose-built CMS and a prerendered front end, handled solo from database schema to deployment.
Overview
The client is a Japanese company that rents and sells renewable-energy and construction-site equipment — solar panels, storage batteries, and safety gear. Their site ran on Wix, where product detail pages were locked to the template and could not be composed the way the business needed.
The rebuild uses no off-the-shelf CMS. The admin and the public site were written from scratch with Nuxt and Astro, so that editors gain real control over how a product page is composed while visitors keep static-site performance. I did all of it, from the database schema to the deployment configuration.
Architecture
Two Cloudflare Workers share one D1 database: the CMS writes, the public site only reads. Media lives in R2, and the browser uploads straight to it with a presigned URL issued by the CMS. Nothing passes through a Worker, which sidesteps both the request-body size limit and the CPU time limit.
The official AWS SDK crashes on the Cloudflare runtime at module load, and its error message points nowhere near the real cause. A pure-fetch signing library fixed it, and the finding is written into a source comment so the next person does not have to rediscover it.
The site prerenders 19 pages at build time, and the product-detail section components render on the server with zero JavaScript via Vue. Only the filtering UI, which needs interactivity, is written as a Solid island; the compile boundary between the two frameworks is enforced by a directory convention.
The hard parts
The section builder behind product detail pages
Product detail pages had to reproduce a fixed set of visual rules: 15 section types across 41 variants. The difficulty was never the count — it was that the editor form, the editor preview, and the production renderer each drift on their own. Add a field to any one variant and all three have to move together.
The fix was to extract sections into their own workspace package, with a single zod schema as the one contract: admin validation and public rendering read the same types. The same Vue components drive the live preview inside the CMS and the zero-JavaScript render on the site, so a second implementation never gets a chance to exist.
Variants whose data shapes differ are expressed as a discriminated union rather than by making every field optional. A missing field then fails at save-time validation instead of surfacing as a runtime error once a visitor opens the page.

Prerendering versus unpublished edits
Prerendering everything has one unavoidable consequence: an editor saves a change and the site shows nothing until the next build. With a single person running the site, nobody remembers to go trigger one from a dashboard.
The fix was to make "unpublished" a first-class state. Every write that affects what visitors see flags a single-row state table on success, and COALESCE keeps the timestamp of the first edit rather than the latest — which is what lets the admin header show how long the backlog has been sitting.
The publish button proxies a deploy hook through the server; the URL carrying the secret is never handed to the browser. Building automatically on every save was rejected: editing ten fields in a row would fire ten builds, so publishing is one deliberate, batched action instead.

Scanning the whole database before deleting an image
The media table is referenced from four places, and only some of them are real foreign keys. Bare ids inside JSON columns and object keys stored in sections are not protected by any database constraint. On delete, the database can only stop one of those cases; missing the rest means a live 404 and a file that cannot be recovered.
- Foreign-key columns compare the id directly
- Numeric arrays are expanded with json_each and matched entry by entry
- Arrays of objects go through json_extract to pull out the media id
- Free-shaped config columns are checked for valid JSON before being expanded
- Section content stores object keys, so it falls back to an escaped substring match
- Any hit returns 409 and lists where the image is used, so the editor knows which entries are holding it
A substring match is imprecise, and that is the deliberate trade: a false positive is one reversible refusal, while a false negative is a deleted file that never comes back. The previous implementation looked for one exact attribute name that nothing in the codebase had ever written, and the scan had silently done nothing for a while.
These references were not normalised into join tables, even though that is the textbook answer. Normalising would attach a synchronisation step to every single content save, whereas deleting media is rare — trading one full scan for that ongoing burden is the better deal.
Live site