This site has two jobs: make my engineering work legible and give me a low-friction place to publish. The interesting work is not the page styling. It is keeping authoring convenient without turning the public site into a runtime CMS application, and making a contact request durable without making the visitor wait for every downstream service.
The constraints#
- Public pages must be statically generated and useful without client JavaScript.
- A draft must not leak through a route, feed, search index, sitemap, metadata, or social image.
- Articles and one-paragraph Notes must use the same publishing system.
- Public copy, taxonomies, navigation, and projects must be editable without changing TypeScript.
- Contact delivery must acknowledge quickly, survive downstream failures, and avoid duplicate records.
Those constraints lead to two deliberately different paths.
View diagram source
flowchart LR
A[Keystatic] --> B[MDX and JSON]
B --> C[Zod validation]
C --> D[Static pages]
C --> E[RSS, search, sitemap, metadata]
F[Contact form] --> G[Validate and sanitize]
G --> R[Redis rate limit]
R --> H[QStash]
H --> I[Gemini analysis or safe fallback]
I --> J[Idempotent Sheets write]
J --> K[Best-effort Telegram alert]
H --> L[Retries and managed DLQ]The read path pays its complexity at build time. Keystatic writes ordinary repository files; shared loaders validate relationships and publication state; Next.js generates the routes and discovery artifacts. The browser receives complete content, then small client leaves add search, filtering, reading progress, theme selection, code-copy feedback, and the mobile dialog.
The contact path pays its complexity at runtime because delivery matters more than rendering. Same-origin, body-size, honeypot, signed form-age, and Redis-backed rate-limit checks reject bad requests before they become background work. The API sanitizes accepted fields, assigns a submission ID, and returns success only after QStash accepts the message. A QStash-signed worker asks Gemini for structured analysis or uses a safe fallback, writes the durable Sheets record under the idempotency key, and sends Telegram only as a best-effort notification. Persistence failures remain retryable; exhausted deliveries reach a managed dead-letter queue.
The contact form is a small distributed workflow#
The form does not call an email API and hope for the best. It separates fast visitor feedback from work that can fail, retry, or arrive more than once.
1. Reject bad work early#
The request crosses same-origin, body-size, honeypot, and signed form-age checks before reaching the public route. Upstash Redis applies the primary per-IP rate limit. The token adds form-age friction; it is deliberately not described as replay prevention.
2. Hand delivery to QStash#
The public route validates raw fields with Zod, sanitizes them, creates a cryptographically random submissionId, and publishes the clean payload to QStash. The visitor sees success only after QStash accepts it. Gemini, Sheets, and Telegram are not on the user-facing latency path.
3. Keep AI bounded#
The process route accepts only QStash-signed deliveries. Gemini receives sanitized data inside explicit XML boundaries and must return a structured result. Its analysis helps classify and summarize the message; it does not decide whether the message is persisted. A timeout, provider error, or invalid model response produces a safe fallback instead of dropping the submission.
4. Persist before notifying#
The worker sends an HMAC-signed payload to the bound Sheets endpoint. The Apps Script holds a lock, checks submissionId, and appends only when that ID is new. A duplicate queue delivery therefore becomes an idempotent success rather than a duplicate row. Telegram runs afterward as a best-effort alert, not as the durable record.
5. Make failure recoverable#
A persistence failure returns a retryable error so QStash can apply exponential backoff. Exhausted deliveries remain in its managed dead-letter queue and trigger a signed callback. The callback attempts a redacted Telegram warning and always returns 200 because retrying that webhook cannot replay the original job.
| Boundary | Responsibility |
|---|---|
| Proxy + Redis | Reject malformed, cross-origin, automated, or excessive work |
| Public API | Validate, sanitize, identify, and enqueue |
| QStash | Authenticate delivery, retry, and retain exhausted messages |
| Gemini | Optional structured enrichment with a safe fallback |
| Sheets | HMAC-verified, locked, idempotent durable record |
| Telegram | Best-effort operational notification |
Decisions that matter#
Files are the public source of truth#
Keystatic is a write interface, not a production read dependency. This keeps local changes reviewable, makes a broken relationship fail during validation, and avoids adding a CMS network request to every page.
One schema owns runtime truth#
Zod schemas define content and API boundaries, and TypeScript types are inferred from them. Keystatic mirrors those constraints for authoring, with parity tests guarding the seam that cannot be shared directly. Errors include the file, field, invalid value, and valid alternatives.
Publication is enforced at every projection#
Filtering a draft only on its page is insufficient. The same published rule applies when generating static parameters, metadata, Open Graph images, RSS, search, sitemaps, topic counts, and series navigation. A known draft slug behaves like an unknown route.
Small client leaves earn their place#
Static generation does not mean removing all state. Navigation, theme selection, URL-backed writing filters, active table-of-contents state, and copy feedback are isolated client components. Page and layout components remain server-rendered.
What I got wrong in v1#
I over-optimized client JavaScript out of the header and replaced a reliable navigation component with persistent native disclosure state. Because the root layout survives client navigation, the mobile menu stayed open after following a link. The correction is a small native-dialog leaf with explicit close, focus, scroll, route-change, breakpoint, and unmount behavior.
Taxonomies, navigation, and public copy were also split across content files and TypeScript registries. That made ordinary editorial changes depend on a code editor and allowed the authoring schema to drift from build-time validation.
Finally, the validator treated every post as long-form. It required a fixed outline, citations, and a Mermaid diagram even when a short Note was the better form.
Failure modes and controls#
| Failure | Control |
|---|---|
| A renamed taxonomy breaks content | Cross-reference validation fails the build with valid alternatives |
| A draft appears in discovery | Publication filtering is tested across every generated projection |
| A duplicate queue delivery appends twice | submissionId is checked under the Sheets lock |
| A downstream provider is unavailable | QStash retries; safe analysis fallback; managed DLQ |
| A menu exit leaves the page locked | Every close path and unmount restores body scroll |
| JavaScript is unavailable | Public content, links, and the unfiltered writing library still render |
Result and remaining trade-offs#
One content graph now produces pages, taxonomy directories, RSS, search, sitemaps, metadata, and social cards. The public read path remains static, while the only custom runtime workflow is isolated and recoverable.
The trade-off is stricter authoring discipline: a slug rename must update its relationships in the same change, and a failed validation blocks release. I prefer that visible build failure to a quiet broken link in production. I also keep Keystatic intentionally bounded; it can reorder and hide known sections, but it is not an arbitrary page builder.