Vellum
RENTAL-SITE-REBUILD 2026

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.

Rebuilding a rental company site and its CMS

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.

Codebase 47,938 lines (TS / Vue / Astro / SQL) across 346 source files
Tests 350 unit tests and 28 E2E specs; CI runs lint, type-check and unit tests, all three at a zero-warning threshold
Timeline About 3.3 months from 2026-04-27, 839 commits, 99.9% authored solo

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 two-Worker architecture over a shared D1: CMS writes, prerendered reads on the public site, direct-to-R2 uploads, and the publish path
The two-Worker architecture over a shared D1: CMS writes, prerendered reads on the public site, direct-to-R2 uploads, and the publish path

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.

LIVE Interactive — try it
Interactive demo: switching a section variant reshapes the editor form around it
The section editor in the CMS: form on the left, live preview on the right, rendered by exactly the same components as the public site
The section editor in the CMS: form on the left, live preview on the right, rendered by exactly the same components as the public site

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.

The persistent unpublished-changes bar and publish button at the top of the CMS
The persistent unpublished-changes bar and publish button at the top of the CMS

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