Personal site: 83 essays, a portfolio, and a chat box that answers from the essays. Live at https://lscaturchio.xyz.
Next.js 16 App Router, React 19, TypeScript, Tailwind, Neon Postgres, deployed on Vercel from main.
Every public write endpoint is the same four-layer chain, and the contact form is the shortest example (src/app/api/contact/route.ts):
- Rate limit — the route is charged against its named policy,
CONTACT(3 per 5 min per client), before anything else runs. Each policy has its own buckets, so the contact form and the newsletter signup never spend each other's allowance. Upstash Redis when configured, an in-memory map otherwise; if Redis errors, the request degrades to the in-memory limiter instead of failing (src/lib/rate-limit.ts). - CSRF —
Originheader checked against the site URL and the exact hostnames Vercel injects for this deployment, never a name prefix: anyone can registerlscaturchio-<x>.vercel.app(src/lib/csrf.ts). - Zod —
contactFormSchematrims and bounds name (100), email, message (5000); a failure is a 400 with the field error (src/lib/validations.ts). - Sanitise at the sink — the email body is built from
escapeHtml/sanitizeForHtmlEmail, and the subject throughsanitizeEmailSubject, which strips\r\nso a name cannot inject mail headers (src/lib/sanitize.ts).
Validation and sanitisation are separate on purpose: Zod decides whether the request is well-formed, the sanitisers decide what is safe in a given output (HTML, a header). Each layer has its own unit tests under src/__tests__/lib/, and src/__tests__/api/contact.test.ts covers the composition.
bun install --frozen-lockfile
cp .env.example .env.local # DATABASE_URL is required for build; the rest is optional
bun run dev # http://localhost:3000bun run lint # eslint + knip + retrieval-corpus drift check
bun run typecheck
bun run test:coverage # vitest, thresholds enforced
bun run build
bun run test:e2e # playwright, builds and serves the app itself
bun run predeploy # all of the aboveCI runs the same five jobs on every PR and they are all required. GitHub's default CodeQL setup scans every push to main; uptime.yml probes production hourly and opens an issue after two consecutive runs find it genuinely down — an edge/WAF refusal that never reached the app is reported as blocked, not as an outage.
Essays are src/app/blog/<slug>/content.mdx. The chat corpus in public/my-data/blog-*.md is generated from them (bun run sync-retrieval-corpus), and CI fails if the two drift. Blog audio is served from Vercel Blob, not git. Procedures for embeddings, audio, database and monitoring are in docs/operations.md; house style for essays is docs/writing-style.md.
MIT.