DEV Community

Cover image for Astro 5 content collections as an editorial layer in a programmatic site
MORINAGA
MORINAGA

Posted on Edited on

Astro 5 content collections as an editorial layer in a programmatic site

The 18 indexed pages on Open Alternative To are structurally identical — same template, same GitHub API data sources, same auto-generated intro. (Counts in this post are as of May 2026; the curation thresholds were later loosened and the site now has 32 indexed pages with a take on every one of them.) That uniformity is useful at build time and a liability at review time. Pages that don't differ in any content requiring editorial judgment are indistinguishable from scraped mirrors.

The fix I reached for is an Astro 5 content collection for per-entry editorial takes. Here's how the pattern works and where it earns its overhead.

What content collections give you here

Astro 5 content collections are typed collections of Markdown or data files. You define a loader and a Zod schema in content.config.ts, and at build time Astro validates every file and gives you typed APIs — getCollection(), getEntry() — that fail the build if a file is malformed or missing an expected field.

The critical property for this use case: a slug with no matching file simply produces no entry, rather than an error. You can conditionally render editorial content only for pages that have it, with no try/catch, no file-existence check, no runtime error. The 15 pages without editorial takes render exactly as before; the 3 pages with takes get the extra section automatically at build time.

The setup

apps/oss-alternatives/src/content.config.ts:

import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
import { fileURLToPath } from "node:url";

const TAKES_DIR = fileURLToPath(
  new URL("./content/per-alternative-takes", import.meta.url),
);

const perAlternativeTakes = defineCollection({
  loader: glob({
    pattern: "**/*.md",
    base: TAKES_DIR,
    // filename is the authoritative id — a frontmatter `slug:` must not
    // silently bind a take to a different page
    generateId: ({ entry }) => entry.replace(/\.md$/i, ""),
  }),
  schema: z.object({
    saas_slug: z.string(),
    author: z.string(),
    last_reviewed: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
    summary: z.string().optional(),
  }),
});

export const collections = {
  "per-alternative-takes": perAlternativeTakes,
};
Enter fullscreen mode Exit fullscreen mode

Two things worth calling out. The base directory is resolved relative to the config file rather than the cwd — building from the workspace root otherwise mis-resolves the path and silently skips the collection, which is exactly the failure this gate is meant to prevent. And generateId forces the entry id to come from the filename, which the page then cross-checks against the frontmatter.

Files live at src/content/per-alternative-takes/{slug}.md. The {slug} matches the saas_slug of the comparison page's data row — so auth0.md, datadog.md, airtable.md. The optional summary field is the one-line intro shown before the full editorial body, and everything after the frontmatter renders as standard Markdown.

The page integration

In pages/alternatives/[slug].astro:

import { getCollection, render } from "astro:content";

const allTakes = await getCollection("per-alternative-takes");

// both the filename (= collection id) and the frontmatter must agree,
// so a drift in either one can't paste a take onto the wrong page
const matchingTakes = allTakes.filter(
  (t) => t.id === saas.slug && t.data.saas_slug === saas.slug,
);

const editorialTake = isCurated(saas) ? matchingTakes[0] : undefined;
const TakeContent = editorialTake ? (await render(editorialTake)).Content : null;
Enter fullscreen mode Exit fullscreen mode

Then in the template:

{editorialTake && TakeContent && (
  <section class="mt-10 border-t border-zinc-200 dark:border-zinc-700 pt-8">
    <h2 class="text-xl font-semibold mb-2">Editor's take</h2>
    <p class="text-sm text-zinc-500 mb-4">
      {editorialTake.data.author} · last revisited {editorialTake.data.last_reviewed}
    </p>
    {editorialTake.data.summary && (
      <p class="mb-3 text-sm italic text-zinc-600 dark:text-zinc-400">{editorialTake.data.summary}</p>
    )}
    <div class="prose dark:prose-invert max-w-none">
      <TakeContent />
    </div>
  </section>
)}
Enter fullscreen mode Exit fullscreen mode

That's the entire integration. No conditional imports, no dynamic requires, no feature flags. getCollection() resolves at build time so the page stays fully static, and render() is what hands back the Content component — the entry object itself doesn't carry one. The TypeScript is clean because editorialTake is either the typed entry or undefined, and the Zod schema has already enforced the required fields, so the template only has to guard the optional summary.

There's one more guard I'd recommend if you copy this: a loop over every take that throws when t.id !== t.data.saas_slug. A take silently attached to the wrong company page is much worse than a failed build.

What it actually costs to run

The Astro setup is about 30 minutes — schema definition, content.config.ts, the template conditional, and smoke-testing the build. That's not where time goes.

Each editorial take is 3-4 hours of writing and verification. The auth0 take required confirming whether AGPL §13 actually triggers when embedding ZITADEL in a closed-source SaaS (it does, specifically because SaaS users "interact with the software over a network"). The datadog take required checking whether Netdata's star count I cited matched the current GitHub figure and whether the Grafana stack sizing estimates I used were from the official docs. The airtable take required reading NocoDB's actual license files — not just the GitHub badge, which can be stale — to distinguish the AGPL core from the hosted-version terms.

At 3-4 hours each, covering all 18 curated pages in editorial depth would be 54-72 hours. That's not the near-term plan. Three takes are enough to demonstrate the pattern and differentiate a subset of pages. The Astro infrastructure is in place; I add takes when I've done the verification work, not on a publishing schedule.

When this pattern is worth it

Content collections as an editorial layer make sense when:

The content is genuinely optional per-entry. If every page should eventually have an editorial section, you're better off adding it directly to the main data model and the programmatic generation step. The content collection is for the incomplete case — where some pages have editorial depth and others don't.

The editorial content is unstructured prose. If it's structured (ratings, dates, license classifications), it belongs in the comparison dataset itself, typed as part of the main SaasEntry schema. The content collection is for markdown that doesn't fit a schema.

You have actual domain knowledge for the specific entries you're writing. Writing editorial takes for software you haven't used and haven't read deeply is worse than having no take at all. A take that gets a detail wrong — say, mischaracterizing which parts of a repo are under the enterprise license — is actively harmful to readers making deploy decisions. The editorial layer has value proportional to the accuracy of the judgment behind it.

The tradeoff I'm watching

The split between the structured comparison data and the content collection (editorial prose) creates two data sources that need to stay loosely synchronized. If a comparison page's curated status changes — say, an alternative loses stars below the 1,000 threshold and the page moves to noindex — the editorial take for that slug still exists in src/content/per-alternative-takes/. The take doesn't break anything, and it doesn't leak onto the noindex page either, because the take is only attached when isCurated(saas) is true. It just goes dark: hours of writing that stops being rendered anywhere, with nothing in the build output telling me it happened.

For 3 takes across 18 pages this is a minor concern. At 18 takes across 80 total pages it would need explicit handling — probably a build-time check that warns when a take exists for a non-curated slug. I'll add that when the number of takes grows past single digits.


Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.

Top comments (0)