Decisions, dated and not softened.
What was chosen, what was considered instead, and the trade-off accepted, each entry sourced from the commit that actually made the call, not written after the fact.
7 entries, newest first.
Content as typed code, not a CMS
Projects, essays, testimonials, and credentials live as typed TypeScript modules validated by Zod schemas at module load. There is no CMS and no database to operate, patch, or breach.
- A headless CMS: editorial convenience, at the cost of an operated third-party service with its own attack surface and uptime dependency.
- A database: nothing on this site actually needs one; the genuinely dynamic surfaces (contact form, rate limiter, AI assistant) don't touch content storage at all.
Every content edit requires a code change and a deploy, not a CMS save button. Accepted because a malformed record then fails the build, not the visitor; there's nothing left to patch or breach in production.
Dropped the keywords meta tag
Removed the `keywords` meta tag from every page's metadata entirely, rather than populating it.
- Populate it anyway, as most starter templates default to.
None found: search engines have ignored the keywords meta tag for over a decade, so keeping it would only be a maintenance cost (one more field to keep honest) for zero SEO benefit. Keyword strategy lives in titles, descriptions, and headings instead.
A status badge that reads real state, never hardcodes it
The hero AI assistant's status badge reads the actual X-Assistant-Mode response header from its own API route and reflects the real serving mode (live Gemini 2.0 Flash vs. curated fallback), rather than ever hardcoding "LIVE."
- Hardcode "LIVE" in the UI regardless of which path actually answered: simpler, but an interface that overstates its own capability costs more credibility than the feature earns.
The badge stays in a neutral state until the first exchange actually proves which path answered, costing a moment of visual ambiguity in exchange for never displaying a claim the system can't back up.
No APM SDK: a dependency-free error webhook instead
Used Next.js's built-in onRequestError instrumentation hook to POST uncaught errors to a configurable webhook URL, rather than installing an APM SDK.
- Sentry or a comparable APM SDK: full-featured (breadcrumbs, session replay, release tracking), but a heavy runtime wrap for a portfolio's actual error volume.
No breadcrumbs, session replay, or release tracking. In exchange: zero new dependencies, and errors reach any JSON-accepting sink (Slack, Discord, a log drain) instead of a silently-failing email provider going unnoticed.
A server-verified HMAC challenge instead of a third-party CAPTCHA
Replaced a client-side-generated-and-verified arithmetic challenge (which meant /api/contact accepted direct POSTs from any script) with a stateless, HMAC-SHA256-signed challenge minted and verified server-side, with a TTL and a minimum-age "time trap."
- A third-party CAPTCHA (reCAPTCHA/hCaptcha): adds a dependency, a third-party script, and a consent/privacy surface.
- Keep the original client-side check: already known insecure; the trust boundary was in the wrong place.
The design is stateless, so a valid token is replayable within its 15-minute TTL. Accepted because the rate limiter already caps submissions per IP, and every replay still required one human-solvable round trip to obtain.
A fresh CSP nonce per request, over static prerendering
A cryptographically random nonce is minted for every request and embedded in script-src as 'nonce-…' 'strict-dynamic', removing 'unsafe-inline' from scripts entirely. Because the nonce changes per request, every document renders dynamically rather than being statically prerendered.
- Static prerendering with a single build-time nonce: defeats the purpose; the same nonce reused forever isn't a nonce, and any script that ever carried it stays trusted indefinitely.
- 'unsafe-inline' script-src: the common default, but it reopens inline-script XSS as an entire class of vulnerability.
Every HTML document costs a server render on each request instead of being served from CDN-cached static output. Accepted in exchange for eliminating inline-script XSS as a class, not just mitigating it.
Upstash Redis via REST, not a heavier queue or a self-managed store
Distributed rate limiting via a fixed-window counter in Upstash Redis's REST API (plain fetch, no SDK), with an in-process sliding-window fallback for dev/unconfigured deployments, and fail-open on backend errors or timeouts.
- A dedicated queue or self-managed datastore for counters: heavier infrastructure than a portfolio's traffic volume justifies.
- In-memory-only limiting everywhere: leaks across serverless instances, since the effective limit becomes (instances × max) rather than a real global cap.
Chose fail-open over fail-closed on limiter outage: for a contact form and a chat assistant, availability outranks strict throttling during the rare window the limiter itself is down.