Why
I wanted a portfolio that reads like an engineer's site, not a template: fast, honest copy, and a real place to publish case studies. I also wanted to own the whole thing, hosting included, instead of renting a form widget and a deploy pipeline.
What
A single-page site with a static case-study route per project and a first-party contact form. Content is plain TypeScript and MDX files with a typed schema that is validated at build time; there is no CMS. A feature flag kept the Work section, its pages and its sitemap entries hidden until there were case studies worth showing. The only server code is a small API for the form, which stores messages in SQLite and exposes a token-protected endpoint to read them.
How
Next.js App Router in standalone mode, prerendered at build so every page is static HTML for crawlers; only /api/* runs per request. The app ships as a Docker image that my self-hosted Coolify instance builds from the repo on every push to main, with a persistent volume for the database and Cloudflare in front as DNS and edge cache. It moved there from a Cloudflare Worker during the August 2026 redesign.
The page is server-rendered and mostly server components. Motion is CSS: the hero reveals with keyframes that run straight from the HTML, and one small IntersectionObserver fades later sections in as they scroll into view. Nothing is hidden in the server markup, so visitors without JavaScript and crawlers see everything, and reduced-motion preferences switch the effects off.
The contact form is protected by a honeypot field, a minimum fill time, a bounded per-IP rate limit and Cloudflare Turnstile. The Turnstile script only loads once you focus the form, so it never costs the page load. Its validation is a small hand-written module shared by browser and server, which keeps a schema library out of the client bundle. Requests are size-capped, and the site sends a Content-Security-Policy and HSTS.
Open Graph cards for the homepage and each case study are rendered at build time from the same content files, so link previews always match the copy.
What I'd do next
Add a writing section using the same MDX loader, and a small admin view for the inbox instead of reading it with curl.
