Personal site of Agustin Gonzalez Nicolini, engineering-leadership coaching. A prerendered React single-page app served as static files from GitHub Pages behind a CloudFront CDN, published in six languages, with a CMS-driven blog and marketing pages, a bot-hardened contact form, and consent-gated analytics. Infrastructure and CI are managed as code.
Live: https://agusgonzaleznic.com
Design rationale, the why behind the decisions below, lives in docs/architecture.md.
Browser ──▶ CloudFront (agusgonzaleznic.com)
├─ default behavior ─▶ GitHub Pages (static site, this repo)
└─ /api/* ───────────▶ Lambda (contact form) via OAC ──▶ SESv2 email to owner
└─ SSM (secrets) + DynamoDB (rate limits)
Storyblok (CMS) ──publish webhook──▶ Lambda ──▶ GitHub Actions (rebuild) ──▶ Pages ──▶ CloudFront invalidation
Build time: Storyblok (content) + DeepL/Claude (translation pipeline) ──▶ static HTML × 6 locales
- Static-site generation.
vite build(client) + an SSR build (src/entry-server.tsx) feedscripts/prerender.mjs, which renders every route per published locale to static HTML so crawlers and AI engines see full content without running JS. Each route gets its own<head>(title/meta/canonical/hreflang/JSON-LD) viareact-helmet. Unknown paths get a real HTTP 404 (dist/404.html); there is deliberately no SPA 200-fallback. - Content from a CMS, safely. Blog posts and all marketing pages live in Storyblok and are fetched at build time (
scripts/fetch-blog.mjs,scripts/fetch-pages.mjs): the CMS token is a build-only environment variable and never reaches the browser bundle. Marketing pages fall back to hardcoded copy when no token is present. A Storyblok publish fires a webhook that triggers a rebuild. - Six languages. English at the root;
de/es/fr/it/ptunder/{locale}/path prefixes. UI strings go through Lingui catalogs; CMS content is machine-translated at build time (DeepL + a Claude voice post-edit) behind a review gate: DE/ES publish only human-reviewed translations, FR/IT/PT auto-publish with a machine-translation disclosure. Seedocs/architecture.mdanddocs/publishing-a-post.md. - Hardened contact form. Submissions POST to a same-origin
/api/contactendpoint (a Lambda behind CloudFront via OAC) that runs ten ordered server-side anti-abuse controls: method gate, CORS allowlist, body-size cap, schema validation, and honeypot first; per-IP and global burst limits before the outbound Turnstile siteverify call (so bots can't saturate it); then token age / minimum-form-time / replay checks (computed from Turnstile'schallenge_ts, so they can only run after verification), a per-email rate limit, and duplicate suppression. It then emails the owner directly via SESv2, no third-party relay. No third-party script loads at page load. - Consent-first analytics. Analytics is off by default and loads only after explicit opt-in; the privacy notice reflects the site's actual behavior.
- Infrastructure as code. DNS, TLS, CDN, SES, and the serverless pieces are defined in Terraform (
terraform/) and applied through gated CI pipelines, including the IAM/bootstrap tier itself. Seeterraform/README.md.
- Vite 7 + React 18 + TypeScript 5 (SWC).
- Tailwind CSS 3 with shadcn/ui (Radix primitives), lucide-react icons.
- React Router 6 for routing; react-helmet for per-route metadata; Lingui 6 for i18n.
- @storyblok/react for CMS content.
- ESLint 9 + Husky + lint-staged (pre-commit lint).
- Fonts are self-hosted (
public/fonts/) withfont-display: optional, so there are no external font requests and no layout shift.
Requires Node.js 22.
npm install
npm run dev # dev server at http://localhost:8080 (runs fetch-blog + fetch-pages first)The blog is empty and marketing pages use fallback copy locally unless a Storyblok read token is provided (see below); everything else runs without any secrets.
npm run dev # dev server (predev runs fetch-blog + fetch-pages)
npm run build # full production build (see the chain below)
npm run preview # serve the built dist/ locally
npm run lint # ESLint
npm run i18n:extract && npm run i18n:compile # Lingui catalog maintenancenpm run build runs, in order:
assert-no-client-secretsfails the build if aVITE_STORYBLOK_*var is present (guard against inlining a CMS token into the public bundle).fetch-blogpulls published blog posts from Storyblok intosrc/generated/, machine-translating them per published locale (review-gated for DE/ES, automatic for FR/IT/PT).fetch-pagesdoes the same for the Storyblok-managed marketing pages (with hardcoded fallback copy when no token is set).build:client+build:serverare the Vite client and SSR builds.prerenderrenders every route × published locale to static HTML and generatessitemap.xml(with hreflang alternates), the blog RSS feed, a real404.html, and per-localellms.txt.
| Variable | Purpose | Notes |
|---|---|---|
STORYBLOK_PUBLIC_TOKEN |
Build-time CMS read | Never VITE_-prefixed: build-time only, never bundled. CI sets STORYBLOK_REQUIRE_TOKEN=1 so production can never ship an empty blog. |
STORYBLOK_VERSION=draft |
Preview unpublished posts locally | Optional; needs a preview token. |
DEEPL_API_KEY |
Machine translation (build-time) | Non-VITE_ by design. Empty → auto translation is skipped entirely: FR/IT/PT ship English (the cache is only consulted with a key); reviewed DE/ES are unaffected. |
ANTHROPIC_API_KEY |
Claude voice post-edit on translations | Non-VITE_ by design. Empty → raw DeepL output. |
VITE_TURNSTILE_SITE_KEY |
Cloudflare Turnstile widget (public site key) | Public by design. Empty → contact form shows an email fallback. |
VITE_GA_MEASUREMENT_ID |
Enable consent-gated analytics | Public by design. Empty → analytics fully disabled. |
VITE_HTTPS=true |
mkcert HTTPS dev server | Only needed for the Storyblok Visual Editor preview. |
Provide them inline or via your own secrets manager. Do not commit real values.
Three GitHub Actions pipelines (all third-party and reusable workflows are pinned by commit SHA):
ci.yml: on PRs, lints and builds the site (only when site files changed, via a skip-job pattern) and reports a single required status check.deploy.yml: on push tomain(site paths only) and on the Storyblok rebuild webhook: builds all locales, deploys to GitHub Pages, and invalidates the CloudFront cache so changes are live immediately.terraform.yml: on PRs/merges touchingterraform/**: two tiers. The site module plans on PRs (sticky comment) and applies behind theterraform-productiongate; the bootstrap tier (state bucket + all IAM) plans read-only, detects changes by plan, and applies behind its ownterraform-bootstrapgate, chained ahead of the site apply. Seeterraform/README.md.
.
├── index.html # Vite entry + static <head> (route-head markers, JSON-LD)
├── public/ # static assets, self-hosted fonts, robots.txt, llms.txt
├── content/ # review-gated translations: i18n-approvals.json, translations/,
│ # pages/, tag-translations.json (committed, human-reviewed)
├── docs/ # authoring guides + architecture/design documentation
├── scripts/ # build-time: fetch-blog, fetch-pages, prerender, generate-feeds,
│ # guards; authoring: new-post, review-translations, translate,
│ # translate-tags, seed-storyblok-pages; shared lib/
├── src/
│ ├── components/ # section components (Hero, About, …), blog/, storyblok/ (CMS
│ │ # blocks), ui/ (shadcn), LanguageSwitcher, LocaleLink, SeoPage
│ ├── pages/ # Index, About, Services, Philosophy, Impact, Faq, Contact, Blog,
│ │ # BlogPost, Links, Legal, StoryblokPage (CMS renderer), NotFound
│ ├── i18n/ # locales.ts (PUBLISHED_LOCALES gate), catalogs/ (.po), helpers
│ ├── lib/ # analytics, blog, pages (CMS loaders), storyblok, turnstile, utils
│ ├── generated/ # build-time blog + page data, per locale (gitignored)
│ ├── entry-server.tsx # SSR entry used by the prerenderer
│ └── App.tsx / main.tsx
├── lingui.config.ts # extracts ALL locales; publishing is gated separately
├── terraform/ # infrastructure as code (see its own README; bootstrap/ = IAM tier)
└── .github/workflows/ # ci.yml, deploy.yml, terraform.yml
- Every change ships via a PR. CI must pass;
mainis protected. - Accessibility & SEO/GEO: semantic HTML, per-route metadata, hreflang reciprocity, JSON-LD, and on-page FAQ text kept identical to its structured-data counterpart.
- URLs stay clean: bare paths, no lingering fragments; locale prefixes only for non-English.
- No secrets in the repo, the bundle, build logs, or PR output. This is a public repository; treat all output as world-readable.
Private and proprietary. Content and branding © Agustin Gonzalez Nicolini.