🔥 Level up with SDI/HDMI Dual Inputs & 4G Bonding!Shop Now →

🔄 Unlock True Bi-Directional Power: 4Kp60 Encode/Decode & USB-to-NDI magic with the A1 Converter!Learn More →

⚡ Ultimate Broadcast Control: Experience Ultra-Low Latency with the Manager Max Bonding Server & Stream Matrix.Discover Max →

🔥 Level up with SDI/HDMI Dual Inputs & 4G Bonding!Shop Now →

Back to Blogs

Engineering Retrospective: Migrating Downloads from TypeScript to Keystatic CMS — Lessons Learned

CNDLive Team
CNDLive Team
cndlive team member

Engineering Retrospective: Migrating Downloads from TypeScript to Keystatic CMS — Lessons Learned

Brand Logo
Streaming The Moment

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:

  1. Open a TypeScript file
  2. Add a new entry with the right structure
  3. Run a build
  4. Push to GitHub
  5. 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:

  1. Click into a category (e.g., "Firmware")
  2. See the item list
  3. Click an item to edit
  4. 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:

  1. For production, Vercel always does a fresh build, so this is never an issue.
  2. 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.ts files 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.ts to Keystatic (A1 done ✓)
  • Remove legacy downloads.ts files 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.