Back to projects
Active Started Nov 2024

rosslabs-ai

Personal portfolio site built with Astro and Keystatic CMS. Showcases projects, posts, and experience.

Go to App Private Repo
Astro Tailwind CSS Keystatic Cloudflare Pages

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

  1. Content is authored as markdown/MDX/JSON under src/content/<collection>/ and validated against the Zod schema for that collection in src/content/config.ts — e.g. the projects collection requires title, description (max 160 chars), group (enum apps | dev-tools | ai-tools | utilities), and techStack: string[].
  2. The shared tagList field runs z.preprocess to strip null/empty rows from relationship arrays before element validation runs. Keystatic’s relationship UI can save an unselected row as null, producing tags: [null]; the reference('tags') element schema rejects null outright, which previously aborted the whole production build with InvalidContentEntryDataError (repro’d at commit 651a38c). A .transform on the array would run after per-element validation, so it wouldn’t catch this — preprocess intercepts the raw input first.
  3. keystatic.config.ts maps each collection to form fields and writes back to the same files Astro reads — no separate content database, no sync step.
  4. src/pages/projects/[slug].astro and src/pages/preview/[slug].astro each set export const prerender = true and populate getStaticPaths() from getCollection('projects') / getCollection('previews'), so those routes are pre-rendered at build time even though the site as a whole runs in server mode. Only routes with real per-request state (the waitlist API) execute on the Cloudflare Worker at request time.
  5. 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 in astro.config.mjs’s markdown.rehypePlugins) assigns matching id attributes to the rendered headings so the anchors resolve.
  6. wrangler.toml binds 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 the null entry → 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.preprocess was chosen over a post-validation .transform for the tagList field because Zod validates array elements before any array-level .transform runs; only preprocessing the raw input intercepts a null element before reference('tags') rejects it.
  • Hybrid rendering — per-route prerender: true inside an overall output: '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.