Colophon
How this site is built, hosted, and deployed — end to end.
Infrastructure
A fully static site served from AWS. No Node server at runtime — every page is pre-rendered HTML sitting in an S3 bucket behind a CloudFront distribution. The comments system is the only dynamic piece, living in a separate serverless backend.
Tech stack
- Framework
- Next.js 16 (Pages Router, static export)
- UI
- React 19, hand-crafted CSS (no Tailwind)
- Fonts
- Inter · EB Garamond (self-hosted woff2)
- Dark mode
- next-themes (class strategy)
- Search
- Pagefind (build-time static index)
- Comments
- Custom API — API Gateway · Lambda · DynamoDB
- Spam guard
- Google reCAPTCHA v3
- Analytics
- Google Analytics 4
- Hosting
- Amazon S3 + CloudFront CDN
- CI/CD
- GitHub Actions (deploy on push to master)
How the build works
Running npm run build does two things in sequence. First, Next.js generates a fully static export into out/ — every page becomes a plain HTML file, with no server-side rendering at request time. Second, the Pagefind CLI crawls that out/ directory and writes a /pagefind/ index folder into it, enabling search without any backend.
Deploying to S3 requires three separate sync passes because Pagefind produces binary index files (.pagefind, .pf_meta) that S3 would misidentify as binary/octet-stream. The first pass syncs everything except those files, the second syncs *.pagefind with Content-Type: application/wasm, and the third syncs *.pf_meta with Content-Type: application/octet-stream. After the sync, a CloudFront cache invalidation on /* ensures visitors immediately see the new version.
Search
Blog post pages tag their body content with data-pagefind-body and their title with data-pagefind-meta="title". At build time, Pagefind crawls these annotations and produces a compact binary index.
On the client, the blog listing page dynamically imports /pagefind/pagefind.js using a webpackIgnore comment so Next.js doesn't try to bundle a file that only exists after the build. A 200 ms debounce on the search input calls pagefind.search(), which returns matching slugs. Those slugs are used to filter the post list that was already loaded at build time via getStaticProps — no network request at search time.
Comments
There are no API routes in this repo. Comments are handled by a separate serverless backend: an Amazon API Gateway endpoint backed by an AWS Lambda function that reads and writes to a DynamoDB table.
Every comment submission includes a Google reCAPTCHA v3 token, which the Lambda verifies with Google before writing to DynamoDB. The frontend validates comment length (name ≤ 80 characters, text ≤ 1 000 characters) before sending, and the Lambda enforces the same limits server-side. The API only accepts requests from this domain, rate limiting is enforced at the API Gateway level, and all credentials are managed as environment variables — none appear in source code.
CI/CD
A GitHub Actions workflow triggers on every push to master. It installs dependencies, runs the build with secrets injected as environment variables, syncs the output to S3, and invalidates the CloudFront cache. The entire pipeline — from a git push to a live updated site — takes roughly two to three minutes.
AWS access is granted through short-lived credentials issued per workflow run via GitHub's OIDC provider — no long-lived AWS keys are stored anywhere. Other configuration values are kept as GitHub repository secrets and never committed to the repo.