This is the strangest case study to write: the project and the article describing it are the same thing. You're reading these notes on the site they describe. The URL in the address bar is also the demo.
I wanted a portfolio that shows how I work more than it tells you with pretty words: lightweight, accessible, bilingual, optimised. First vanilla, now React. The migration is documented right here.
At a glance
The numbers that matter, before you read the rest.
~246KBFirst-paint payloadGzipped, fully self-hosted — no third-party CDN
0Cross-origin requestsAll self-hosted, no third-party CDN
2Total font filesVariable WOFF2, down from 6 separate files
100%Absolute paths in HTMLThe 404 works at any URL depth
Self-imposed constraints
No runtime dependencies from third-party CDNs, no cross-origin requests at first paint. React and all its dependencies are bundled locally by Vite: no <script> tags from unpkg, jsDelivr, or cdnjs.
Bilingual IT/EN with the same client-side i18n as Eolietech (I liked it too much to not reuse it). Light/dark theme that follows the OS but remembers user preference, plus an optional Liquid Glass mode kept independent from theme choice. No flash of wrong state on first paint.
Stack
Vite 8 with React 19 and TypeScript, react-i18next for translations, react-router-dom v7 for SPA routing, and React 19's native document metadata for per-route meta tags. Outfit and JetBrains Mono fonts remain self-hosted as variable WOFF2 files: zero external runtime dependencies.
Choosing React over a heavier framework was deliberate: no SSR, no server components, just a lightweight SPA with per-page lazy loading. The main bundle is ~412KB (gzip: ~128KB); react-leaflet, react-i18next, and each page are separate chunks loaded on demand.
app/ → dist/
The repo has a single source of truth: app/. Inside is the React source, JSON translations, hooks, and lazy-loaded pages. Vite produces dist/, the bundle ready for Cloudflare, with content-addressed, immutable assets. dist/ is gitignored: it's regenerated with npm run build before each deploy.
Vite content-addresses every JS/CSS chunk: index-XXXX.js, HomePage-XXXX.js. These are served with Cache-Control: immutable by Cloudflare. A full year of cache per user. Only index.html and theme-boot.js get a short cache window, because those change with each release.
The optimization journal
The first version loaded from Google Fonts: one round-trip for the CSS, six separate WOFF2 files for Inter's four weights and JetBrains Mono's two, two preconnects to third-party domains. ~120KB of fonts plus ~15KB of Google CSS. FCP sat around 2.7s on mobile.
The big leap was self-hosting the fonts as variable fonts. Outfit is a single 32KB WOFF2 for Latin covering every weight 100–900 (plus a 15KB Latin-Extended subset); JetBrains Mono is a single 31KB. I removed the preconnects, eliminated the cross-origin call, and added <link rel="preload"> for both families.
The rest was maintenance. With the migration to Vite, the pre-paint theme script is back to an external file (/theme-boot.js, blocking in <head>) rather than inline: no wrong-theme flash, no unsafe-inline. Vite handles tree-shaking, code-splitting, and content-hashing automatically.
Cloudflare, beyond deploy
A _headers file in the repo sets immutable caching for fonts and Vite assets (one year, via content-hash), no caching for index.html. It also adds the security headers Lighthouse rewards: HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy.
Early Hints is enabled: Cloudflare sends a 103 Early Hints response before the actual HTML response, declaring the fonts and critical CSS. The browser starts fetching them while the server is still preparing the HTML. One round-trip of latency saved on first paint.
Auto Minify and Rocket Loader are off: Vite already handles minification and bundling, and Rocket Loader would interfere with the synchronous theme-boot.js block in <head>.
On smaller details: X-Content-Type-Options: nosniff is explicit on the /api/commits JSON responses, Permissions-Policy has dropped interest-cohort (the FLoC-era directive, now dead), and the whole thing keeps working when GitHub answers badly: the Workers cache keeps the previous response as a stale fallback for a few minutes, then degrades to an empty cacheless JSON instead of surfacing an error to the commits widget.
In front of the SPA sits a unified Cloudflare Worker. It handles every request: serves index.html for all non-asset paths (SPA fallback), intercepts social bots to return a minimal shell with the correct Open Graph tags for each page, and exposes /api/commits, /api/metrics, and /api/health.
The CSP is now static: script-src 'self' (no inline scripts, the SPA is all in files), style-src 'self' 'unsafe-inline' (required for React inline style attributes), img-src 'self' data:, connect-src 'self'. Migrating to React removed all inline scripts from the HTML, so nonces are no longer needed.
docs/cloudflare/unified-worker.reference.js is the source of truth for the production edge. _headers and _redirects document the asset configuration. Pages Functions (docs/cloudflare/functions/) are maintained as documentation for the fallback plan.
Reliability: the build is a single command
The build is a single command: npm run build inside app/. Vite handles TypeScript, bundling, tree-shaking, and hashing. Before each deploy: npm run build, visual check of the dist/ diff, wrangler deploy. Three steps, all by hand — the deploy itself never runs from CI.
The deploy stays manual by design. Every push to main is a commit I verified locally, no bot auto-deploying on every PR. The app/ → dist/ separation is the same logic as the old source/ → root setup: if app/ changes but dist/ isn't regenerated, the deploy carries stale content.
CI exists now, but only to verify, never to deploy: a GitHub Actions workflow runs the typecheck, the Vitest unit and component suite, a production build, a gzipped bundle-size budget, and a Playwright smoke test on every push and pull request. Production still ships by hand — every push to main is a commit I built and checked locally, then deployed with wrangler myself. The repo has AGENTS.md that briefs AI tools on the project context; the practical lesson (an in-session agent had already run npm install and wrangler deploy when it shouldn't have) is exactly why the pipeline verifies but never touches production.
i18n & Global SEO
react-i18next with bundled JSON resources: no network call for translations. Keys are flat (case.eolietech.briefP1, not nested) with single-brace interpolation. Language detection: ?lang= query param → localStorage → Accept-Language. Same logic as the vanilla version, now in a useLang hook.
The flat key pattern was already the right call in the vanilla version, and here it became explicit in the i18next configuration. Changing language is i18n.changeLanguage(), which updates all components via Context without reloading the page.
Performance
First-paint payload for the home page is ~246KB gzipped and fully self-hosted: the SPA shell (~4KB), the stylesheet (26KB gzip, 131KB minified), the entry bundle (~128KB gzip, 412KB raw: React, the router, and shared app code), the i18next bundle (~18KB gzip), the lazy home-route chunk (~8KB gzip), and the two preloaded variable fonts (Outfit and JetBrains Mono, ~62KB combined). It's heavier than the old vanilla build — that's the cost of a full React SPA — but every byte is first-party, with no cross-origin request to Google Fonts, and most of it is cached immutably after the first visit. Per-route code-splitting keeps each extra page under ~10KB gzip; the Leaflet map loads only on /stats (~45KB gzip). Exact Lighthouse numbers are still being measured after the latest round of changes, but the stated target is 100/100 across all four metrics.
Where it stands
You're looking at it. May 2026: migration from vanilla HTML/CSS/JS to Vite + React 19 complete. Static CSP, SPA fallback in the Worker, per-page lazy loading. Production ships from app/ → dist/ after a manual build. I'll keep updating these notes as new projects and real mistakes come in, it's a workshop, not a monument.
If I started over
The one thing I would change is waiting too long to move to React. The vanilla pattern worked well, but adding a new page meant touching the build script, the global CSS, and the vanilla JS. With Vite, adding a page is creating a file in pages/. The source/dist separation was a workaround for an architectural limitation: the real fix was the build step.