Navic Inc. — Create division
Rebuilding a rental company site and its CMS
Rebuilt the site for the Create division of Navic Inc.: a Nuxt 4 CMS and an Astro 7 front end sharing one Cloudflare D1. 47,938 lines, 378 tests, 3.3 months solo.

Overview
The client is the Create division of Navic Inc. (navic-inc.jp), renting and selling 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 4 and Astro 7, 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, navic-rental: the Nuxt 4 CMS writes, the Astro 7 site only reads. Media lives in R2, and the browser uploads straight to it with a presigned URL the CMS signs using aws4fetch, never passing through a Worker.
The official AWS SDK throws loadConfig is not a function at module load on workerd, because its dependency chain calls Node fs at the top level. Switching to aws4fetch fixed it; the finding is recorded in the comments of r2-presigner.ts.
The site prerenders 19 pages at build time and keeps only 3 API endpoints on the runtime. Section components render with zero JavaScript via Vue; the filtering island is Solid, and the compile boundary is enforced by the include directories in astro.config.mjs.
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, @repo/sections, with a 340-line zod schema as the one contract: admin validation and public rendering read the same types. 27 Vue components drive the tiptap NodeView preview inside the CMS and the zero-JavaScript render in Astro.
Variants whose data shapes differ are expressed with discriminatedUnion 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. Write paths call markBuildPending on success to flag the single-row build_state table, and the SQL uses COALESCE(pending_since, ?) so the first edit time is kept, which is what lets the banner show the backlog age.
The publish button posts to /api/build/trigger, and the server fetches WEB_DEPLOY_HOOK_URL; 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, but only featured_media_id and cover_media_id are foreign keys. images_json, parts_json and gallery_json hold bare ids, and content_json holds R2 object keys — none of them constrained. Missing one means a 404 and an R2 object 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 an R2 object that never comes back. The previous implementation looked for a data-media-id attribute 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 delete-then-insert 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