Engineering Retrospective: Migrating Downloads from TypeScript to Keystatic CMS — Lessons Learned
Engineering Retrospective: Migrating Downloads from TypeScript to Keystatic CMS — Lessons Learned
2026-08-24 · Engineering Team · 6 traps, 6 solutions, 1 complete migration.
Why We Did This
Our product download pages (firmware, documentation, software) were powered by hardcoded downloads.ts files — one per product. Every new firmware release required a developer to:
- Open a TypeScript file
- Add a new entry with the right structure
- Run a build
- Push to GitHub
- Wait for Vercel to redeploy
We wanted non-technical teammates to manage downloads directly through a web UI. Enter Keystatic CMS.
Here's everything we learned along the way.
Trap 1: Astro Content Collection vs. Independent mdoc Files
The Problem
We initially placed Keystatic-generated .mdoc files inside src/content/products/{slug}/. Astro's Starlight framework was scanning this directory as a content collection and hitting a wall — the Keystatic frontmatter schema didn't match Starlight's expected schema, causing build crashes.
The Solution
Isolate, don't integrate. We moved all Keystatic singleton files to a dedicated directory:
src/content/product-downloads/{product-slug}.mdoc
This directory is completely separate from Starlight's content collections, so there's zero schema conflict.
Trap 2: Astro Doesn't Parse Frontmatter From Independent mdoc Files
The Problem
Astro's import.meta.glob('/src/content/product-downloads/*.mdoc') looked perfect in theory. But when we tried mod.frontmatter or mod.data, both were empty — Astro/Vite only exports { Content, getHeadings } for mdoc files that aren't part of any content collection.
The Solution
Skip Astro's import system entirely. We switched to raw Node fs.readFileSync() to read the mdoc file as plain text, then parse the YAML frontmatter ourselves.
const raw = readFileSync(mdocPath, 'utf-8'); const fm = extractAndParseFrontmatter(raw); // our own parser
Trap 3: Writing a Minimal YAML Frontmatter Parser (and Why)
The Problem
Astro/Vite doesn't parse independent mdoc frontmatter, and we didn't want to add a heavy YAML library. So we built a minimal parser covering exactly what Keystatic generates:
- Scalar values (
key: value) - Nested objects (indented keys)
- Arrays with inline objects (
- key: value) - Literal blocks (
|/|-/|+for release notes) - Folded blocks (
>/>-/>+for multi-line descriptions)
The Key Insight
YAML arrays don't require the first - item to be at exactly baseIndent + 2. The real indentation of the first dash is what matters. Our parser auto-aligns baseIndent to the first actual dash position:
if (realIndent > baseIndent) baseIndent = realIndent;
This single fix resolved 90% of our parsing failures.
Trap 4: import.meta.url Points to Packaged Code in Production
The Problem
We used import.meta.url to locate the project root:
const selfPath = fileURLToPath(import.meta.url); const srcDir = resolve(dirname(selfPath), '..');
This worked perfectly in npm run dev but silently failed on Vercel. Why? Because Vite rewrites all modules during the build step — import.meta.url points to the compiled chunk path (.astro/chunks/xxx.mjs), not the original source file. The path went wrong, existsSync() returned false, and our loader silently fell back to legacy data.
The Solution
Use process.cwd() — it's the most reliable reference point across dev and production:
const productDownloadsDir = resolve(process.cwd(), 'src', 'content', 'product-downloads');
We added 3 fallback layers (import.meta.url, parent directory traversal) for edge cases, but process.cwd() handles 99% of real-world scenarios.
Trap 5: Keystatic's Nested Array UI Was Too Deep
The Problem
Our first schema used a two-level structure: Categories → Items. This meant opening Keystatic required:
- Click into a category (e.g., "Firmware")
- See the item list
- Click an item to edit
- Save
For teams managing just 2-3 firmware entries per product, this was 3 clicks too many.
The Solution
Flatten the structure. We removed the Category nesting and made each item carry its own category selector:
items:
- category: Firmware # inline dropdown
title: A1_V1.00.0026
date: '2025-12-15'
isBeta: false
releaseNotes: >-
...
The loader auto-groups items by category when rendering on the website. The Keystatic UI now shows a flat list where every entry is immediately visible and editable.
Trap 6: Import.meta.glob Doesn't Pick Up New Files After Startup
The Problem
import.meta.glob evaluates at build time (or server start in dev mode). When we saved a new .mdoc file through Keystatic while the dev server was running, the glob cache didn't include it. The page showed 3 legacy downloads instead of 1 Keystatic entry.
The Solution
Two things:
- For production, Vercel always does a fresh build, so this is never an issue.
- For local dev, restart the dev server after new file creation — or just trust the loader's fallback chain (Keystatic → legacy TS → empty array) and verify during the next build.
Final Architecture
Keystatic UI (web form)
↓ saves
src/content/product-downloads/{slug}.mdoc
↓ fs.readFileSync() + our YAML parser
loadProductDownloads(productId)
↓ priority chain
1. Keystatic mdoc (preferred)
2. Legacy downloads.ts (fallback)
3. [] (empty)
↓ grouped by category
DownloadList.astro → website page
Key design decisions:
- Single source of truth: Keystatic mdoc files are the canonical data source
- Graceful fallback: Old
downloads.tsfiles still work during migration - Zero extra dependencies: No YAML library needed
- Dev + Prod parity: Same code path, same behavior
What's Next
- Migrate all 7 products from
downloads.tsto Keystatic (A1 done ✓) - Remove legacy
downloads.tsfiles once migration is complete - Add batch operations (bulk delete, reorder) to the Keystatic UI
- Consider adding a "diff view" showing what changed between saves
Want to know more? Reach out to the engineering team or open a PR with suggestions.