04 Website build and hosting
RegenerateMX
The bilingual website of a concierge business in Cancún. It’s written in React and prerendered, so every page, in English and in Spanish, reaches the browser as finished HTML.
- Role
- Website build, hosting and deploys
- Built with
- React 18, Vite 6, react-dom/server, nginx, Cloudflare
- Languages
- English and Spanish on every route
- Lighthouse
- 100 / 100 / 100 / 100 on the live home page, mobile, 9 October 2026
- Address
- regeneratemx.org
Problem
An empty page, in two languages
A React single-page app sends an empty page and builds it with JavaScript. For a business that people find through search, in English and in Spanish, that’s the wrong default: crawlers and slow phones see nothing until the bundle has run.
Constraints
No browser on the server
The server has no browser installed, so prerendering had to work without one. Pages had to stay fast on a mid-range phone with video in the hero, and nginx had to answer every address with the right status, including the addresses that don’t exist.
What I built
Finished HTML for every route
A prerender script renders each route with react-dom/server into a flat HTML file, the Spanish routes included, each with its own title and description. Routes are split with React.lazy, and only the current route’s chunk is fetched before the first render. The whole stylesheet is inlined, about 12 kB gzipped, so the first paint needs a single request.
The build also runs gates for things a reviewer misses: banned words in the copy, colour contrast, links that would drop a Spanish reader into English, missing translations, sitemap entries without a page. Each gate was tested by planting the exact mistake it should catch. A separate contract test runs against the live site: route statuses, real 404s, cache and security headers, and the lang attribute on every route.
Planting the mistake is how you know a gate is connected to anything.
What broke
The regression log
Eight changes that shipped, broke something you could measure, and were fixed. Step through them in order.
-
Build
Splitting React into its own chunk
- What happened
- A
manualChunksrule put react and react-dom in a chunk of their own. The chunks then initialised in a circle and every route rendered an empty page, while the build, the linter and every chunk’s 200 response all looked fine. - Measured
- A blank page on every route
- Fix
- Let Vite decide how to split vendor code.
-
Performance
Inlining only the critical CSS
- What happened
- The above-the-fold CSS was inlined and the rest loaded later. When the rest arrived, the hero reflowed.
- Measured
- Cumulative layout shift 0.984
- Fix
- Inline the whole stylesheet, about 12 kB gzipped.
-
Performance
Preloading every route
- What happened
- All 23 route chunks were preloaded. On a throttled connection they competed with the entry chunk for bandwidth.
- Measured
- First contentful paint 1.2 s → 2.3 s
- Fix
- Preload nothing; fetch only the current route’s chunk before the first render.
-
Best practices
hydrateRoot before the markup matched
- What happened
- Switching to
hydrateRootsurfaced a hydration mismatch between the prerendered page and the client. - Measured
- Best practices 100 → 96
- Fix
- Back to
createRootuntil server and client markup matched. Once 20 routes hydrated cleanly,hydrateRootcame back:createRoothad been re-rendering the prerendered page, and Chrome timed the largest paint on the second copy.
-
SEO
Routes as folders
- What happened
- Each route was prerendered as a folder with an
index.htmlinside, so nginx answered every internal link with a 301 to the trailing-slash address. - Measured
- SEO 69
- Fix
- Write flat
.htmlfiles and have nginx try$uri.htmlbefore$uri/.
-
SEO
A logo that linked to /Home
- What happened
- The logo linked to
/Home, a route that was never prerendered, so a click landed on the noindex 404 page. Every page carried that link. - Measured
- SEO 69 on that page, while the 14 real routes scored 100
- Fix
- The link helper returns
/for the home page, and/Homeredirects there with a 301.
-
SEO
A 404 page served as 200
- What happened
- Unknown addresses fell back to the 404 page through
try_files, which serves it with status 200, so search engines could index a typo as a real page. The nginx comment above the block said it did the opposite. - Measured
- Status 200 for pages that don’t exist
- Fix
error_page 404plus a named location that returns 404, and a live test that asks for a missing page.
-
Headers
A security policy that never arrived
- What happened
- The Content Security Policy was set at server level. Every location also sets an
add_headerof its own, and nginx then drops the inherited ones, so the policy compiled, showed up innginx -T, and was missing from every response. - Measured
- No CSP header on any response
- Fix
- Put the security headers inside every location, and check them from outside on every CI run.
What changed
What the log left behind
The 404 and header mistakes are now caught by the live contract test, which runs in CI; the others are written down as rules beside the deploy steps they would break. On 9 October 2026 the live home page scored 100 in all four Lighthouse categories on mobile, and later runs landed between 97 and 100, mostly depending on how the hero video loaded.