rosslabs-ai
Personal portfolio site built with Astro and Keystatic CMS. Showcases projects, posts, and experience.
Problem
A portfolio spanning ~19 shipped apps/tools plus long-form technical writing (agent-architecture deep dives, benchmark write-ups, failure-mode catalogs) doesn’t fit a blog template or a page-builder. A blog template has no structured project metadata — tech stack, status, group ordering, live/repo URLs — so every entry degrades to freeform HTML. A page builder (Squarespace/Wix/Webflow-class tools) has no schema enforcement at all: adding a 20th project means hand-copying markup and hoping the fields stay consistent. Neither gives an editor a typed form for “add a project” that guarantees every entry has a title, a ≤160-char description, and a valid status.
Approach
The design bet: typed content collections (Astro + Zod) as the source of truth, with Keystatic as a git-backed editing UI layered on top of the same files — not a headless CMS with a remote database. Content lives in src/content/** as markdown/MDX/JSON and is validated at build time against schemas in src/content/config.ts; Keystatic’s admin panel (keystatic.config.ts) reads and writes those same files, so a schema change and its content migration land in one commit.
Deliberately not done: no hosted CMS (Contentful/Sanity-class) — that would decouple content state from the code that renders it and add a network dependency for every edit. No SPA/client-rendered framework — the site is HTML-first with React used only for genuinely interactive islands (toolkit grid, waitlist modal), so most routes ship no client JS.
Architecture
flowchart LR
A[Editor: Keystatic admin UI] -->|reads/writes| B[src/content/**\nmarkdown/MDX/JSON]
B -->|validated by| C[src/content/config.ts\nZod schemas, 26 collections]
C --> D[Astro build]
D -->|prerender: true\ngetStaticPaths over getCollection| E[Static content routes\n/projects/[slug], /preview/[slug]]
D -->|output: server| F[Dynamic routes\n/api/*, waitlist]
F --> G[Cloudflare D1\nrosslabs-waitlist]
E --> H[Cloudflare Workers\n@astrojs/cloudflare adapter]
F --> H
astro.config.mjs sets output: 'server' with the @astrojs/cloudflare adapter for the whole site, but individual content routes opt back into static generation per-route.
How it works
- Content is authored as markdown/MDX/JSON under
src/content/<collection>/and validated against the Zod schema for that collection insrc/content/config.ts— e.g. theprojectscollection requirestitle,description(max 160 chars),group(enumapps | dev-tools | ai-tools | utilities), andtechStack: string[]. - The shared
tagListfield runsz.preprocessto stripnull/empty rows from relationship arrays before element validation runs. Keystatic’s relationship UI can save an unselected row asnull, producingtags: [null]; thereference('tags')element schema rejectsnulloutright, which previously aborted the whole production build withInvalidContentEntryDataError(repro’d at commit651a38c). A.transformon the array would run after per-element validation, so it wouldn’t catch this —preprocessintercepts the raw input first. keystatic.config.tsmaps each collection to form fields and writes back to the same files Astro reads — no separate content database, no sync step.src/pages/projects/[slug].astroandsrc/pages/preview/[slug].astroeach setexport const prerender = trueand populategetStaticPaths()fromgetCollection('projects')/getCollection('previews'), so those routes are pre-rendered at build time even though the site as a whole runs inservermode. Only routes with real per-request state (the waitlist API) execute on the Cloudflare Worker at request time.- The table of contents on project pages re-reads the raw markdown file via
import.meta.glob(..., { query: '?raw' }), regexes headings with/^(#{2,4})\s+(.+)$/gm, and slugifies each match (lowercase, strip non[a-z0-9-]) to build the anchor list.rehypeSlug(registered inastro.config.mjs’smarkdown.rehypePlugins) assigns matchingidattributes to the rendered headings so the anchors resolve. wrangler.tomlbinds one D1 database (rosslabs-waitlist) for the single stateful feature on the site — early-access signups. Everything else is static content served from the edge with no database round-trip.
Tech stack
Astro (output: 'server' + @astrojs/cloudflare adapter), Keystatic (git/local-backed CMS, no hosted backend), Zod (schema validation across 26 content collections), Tailwind v4 via @tailwindcss/vite, React islands (deduped to a single instance via Vite’s resolve.dedupe to avoid a duplicate-React hook-call crash), Cloudflare Workers + D1.
Results
Worked example, from the tagList preprocessing step above:
- Input from Keystatic:
tags: [null, "ref-agent-tools"] - Without
z.preprocess:reference('tags')element validation fails on thenullentry →InvalidContentEntryDataError, production build aborts. - With
z.preprocess: the array is filtered to["ref-agent-tools"]before element validation runs → the entry saves and the build succeeds.
⚠️ No build-time or runtime performance benchmark exists for the site (page weight, TTFB, Lighthouse score). That would need to be measured, not asserted.
Lessons
- Git-backed Keystatic was chosen over a hosted headless CMS because content then version-controls alongside the code that renders it — a schema change and its content migration ship in the same commit instead of two systems drifting out of sync.
z.preprocesswas chosen over a post-validation.transformfor thetagListfield because Zod validates array elements before any array-level.transformruns; only preprocessing the raw input intercepts anullelement beforereference('tags')rejects it.- Hybrid rendering — per-route
prerender: trueinside an overalloutput: 'server'config — was chosen over a fully static build because the waitlist feature needs a real server (D1-backed API routes), while the bulk of the site has no reason to hit the origin on every request.