Website builder (planned)
Status: plan, not built. Nothing in this document exists in the codebase yet. It is written down so the decision survives — see
wiki/projects/Bronit Website Builder.mdin the Obsidian vault for why this shape was chosen over the alternatives.
A VIP module that lets a hotel edit the text and images of a website we built for them, from the Bronit dashboard, without a developer, a pull request or a redeploy.
The three existing sites
D:\work\hotels\ — all Next.js 16 + next-intl, forked from one template, so a
single integration works for all three.
| Text | new-translations/{en,ru,uz}.json — 27 sections, 460 strings, 32 KB |
| How it loads | src/i18n/request.ts does import en from "../../new-translations/en.json" — build time |
| Images | ~12 MB in public/images, referenced as hardcoded string literals across 25 .tsx files |
| Repos | Three separate GitHub repos under Botirjon777. No CI workflows. |
Two things to fix before any automation touches these:
safir-hotel’s git remote points atfergana-hotel.git, whilefergana-hotelpoints atferganahotel.git. On a VPS where deploys aregit pull, that mislabelling eventually pulls the wrong site onto a customer’s domain.next.config.tsallows onlyflagcdn.cominimages.remotePatterns— see the uploads note below for why this ends up not mattering.
Architecture: self-hosted, content served at runtime
Both the platform and the customer sites run on our VPS. GitHub stays the source of truth for code; the platform becomes the source of truth for content. Those are different things and deliberately do not share a pipeline — which is what removes the GitHub write token, the rebuild-per-edit, and the binary bloat a git-based CMS would have cost.
/var/www/
bronit/ platform, pm2, :3000
sites/
safir-hotel/ git clone → pm2, :3101
fergana-hotel/ :3102
merhaba-hotel/ :3103
uploads/ shared; UPLOADS_DIR points hereThe uploads trick
nginx serves /uploads/ from the shared directory on every customer domain:
location /uploads/ { alias /var/www/uploads/; expires 30d; }Uploaded images therefore become same-origin on the hotel’s own domain. No
remotePatterns change, no CORS, no CDN, no mixed content, and next/image
optimises them like any local file. src/lib/uploads.ts already supports
UPLOADS_DIR and its comment already describes this exact nginx setup.
Content flow
Dashboard edit → MongoDB (draft) → Publish
├→ POST http://127.0.0.1:310x/api/revalidate
└→ site GETs http://127.0.0.1:3000/api/site-content/<key>Site-to-platform calls go over localhost — no TLS, not publicly exposed, sub-millisecond. Content is public anyway (it is a website), so reads need no auth; only the revalidate hook carries a shared secret.
Fallback is not optional. Each site keeps its committed
new-translations/*.json and falls back to it when the platform is
unreachable. A Bronit outage must never blank a customer’s homepage — it should
serve slightly stale copy and nothing worse.
What changes inside each website
Mechanical, once per site. Structure, routing and components are untouched.
src/i18n/request.ts— fetch messages from the API and deep-merge over the imported JSON,next: { revalidate: 300 }. Local JSON wins on failure.siteImage(path)helper — consults an override map from the same payload; an unmapped path returns unchanged. Touch the 25.tsxfiles once./api/revalidate— secret-guardedrevalidateTag..env—BRONIT_API=http://127.0.0.1:3000,SITE_KEY=safir.
Platform side
Models
| Model | Holds |
|---|---|
Website | companyId, key, name, domain, port, repoUrl, status |
SiteContent | websiteId, locale, path (Hero.title), value, updatedBy/At |
SiteImage | websiteId, slot (/images/hero.webp), uploadUrl |
SiteContentDraft | staged edits, published atomically |
Seed from reality. Import each site’s existing en/ru/uz.json at
onboarding, so day one shows the hotel their real copy rather than empty
fields. The 27 sections the JSON already defines become the editor’s navigation
for free.
Dashboard. New Website builder entry, gated on a new website feature
key (VIP), owner-only. Site dropdown when a company has more than one. Editing
grouped by section, three locale tabs per field, image slots through the
existing processAndStoreImage pipeline. Draft → Preview → Publish.
Onboarding a new site
Scripted, not manual:
clone repo → npm ci && npm run build → assign port → pm2 start
→ nginx vhost → certbot → import JSON → create Website row → point DNSFailure modes designed for
| Risk | Mitigation |
|---|---|
| Platform down → customer sites blank | Committed JSON fallback; sites never hard-depend on us |
| Bad content published | Draft/publish, content history in Mongo, one-click revert |
| A site’s build eats the box | Build to a temp dir, swap symlink; pm2 memory caps |
| Uploads lost on redeploy | UPLOADS_DIR already lives outside the project |
| VPS becomes a single point of failure for customer sites | Nightly uploads/ + Mongo backup, documented restore |
Phasing
- Fix the remotes; VPS layout; nginx
/uploadsalias. - Platform: models, importer, read-only viewer of Safir’s real content.
- Text editing + publish; wire Safir only.
- Image overrides +
siteImage(). - Roll out Fergana and Merhaba; write the onboarding script.
Open questions
- VPS RAM. Each Next site in production is roughly 150–250 MB resident.
Platform + 3 sites is comfortable on 4 GB, tight on 2 GB. It also decides
whether each site gets its own
next startor they share a build server. - Do hotels publish directly, or propose changes for approval? The difference between a Publish button and a review queue. Much cheaper to decide now than to retrofit.
- Scope creep boundary. Text and images only, or eventually add/remove items (rooms, news, special offers)? The latter is a substantially larger build and changes the data model from key/value to collections.