Skip to Content
Bronit HotelWebsite builder (planned)

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.md in 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.

Textnew-translations/{en,ru,uz}.json — 27 sections, 460 strings, 32 KB
How it loadssrc/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
ReposThree separate GitHub repos under Botirjon777. No CI workflows.

Two things to fix before any automation touches these:

  1. safir-hotel’s git remote points at fergana-hotel.git, while fergana-hotel points at ferganahotel.git. On a VPS where deploys are git pull, that mislabelling eventually pulls the wrong site onto a customer’s domain.
  2. next.config.ts allows only flagcdn.com in images.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 here

The 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.

  1. src/i18n/request.ts — fetch messages from the API and deep-merge over the imported JSON, next: { revalidate: 300 }. Local JSON wins on failure.
  2. siteImage(path) helper — consults an override map from the same payload; an unmapped path returns unchanged. Touch the 25 .tsx files once.
  3. /api/revalidate — secret-guarded revalidateTag.
  4. .env — BRONIT_API=http://127.0.0.1:3000, SITE_KEY=safir.

Platform side

Models

ModelHolds
WebsitecompanyId, key, name, domain, port, repoUrl, status
SiteContentwebsiteId, locale, path (Hero.title), value, updatedBy/At
SiteImagewebsiteId, slot (/images/hero.webp), uploadUrl
SiteContentDraftstaged 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 DNS

Failure modes designed for

RiskMitigation
Platform down → customer sites blankCommitted JSON fallback; sites never hard-depend on us
Bad content publishedDraft/publish, content history in Mongo, one-click revert
A site’s build eats the boxBuild to a temp dir, swap symlink; pm2 memory caps
Uploads lost on redeployUPLOADS_DIR already lives outside the project
VPS becomes a single point of failure for customer sitesNightly uploads/ + Mongo backup, documented restore

Phasing

  1. Fix the remotes; VPS layout; nginx /uploads alias.
  2. Platform: models, importer, read-only viewer of Safir’s real content.
  3. Text editing + publish; wire Safir only.
  4. Image overrides + siteImage().
  5. 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 start or 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.