Conclusion first: every file path in my Astro config is anchored to import.meta.url, and I wrote it that way the day I added the collection rather than after getting burned. When I went back to check whether the fear was justified, Astro 5's actual behaviour turned out to be milder than the version of it in my head — the glob loader resolves a relative base against the Astro project root, not against process.cwd(), and it does emit a warning when the directory is missing. This post is about what the loader actually does, and why I still think the absolute-path habit is worth having in a monorepo.
What content collections are doing in this context
The Open Alternative To site generates one page per SaaS product — /alternatives/notion/, /alternatives/slack/, /alternatives/airtable/ — 80 of them, driven by a getStaticPaths() over src/data/saas.json. Categories like "Notes & Docs" and "Team Chat" are a separate route (/categories/[category]/). Each product page follows the same template: GitHub repo data fetched by ETL, star counts, language tags, license information.
That uniformity is useful at launch. It becomes a liability once pages start getting indexed. Pages that don't differ in any editorially meaningful way are one step away from AdSense's scaled-content policy. For products I've actually used, I want to add a first-person take — something that reads as genuine evaluation rather than templated output.
Astro 5 content collections as an editorial layer fit this well: typed, validated at build time, and a missing entry is simply absent rather than an error. Pages without a take render normally; pages with a take get an extra section. No runtime cost, no per-page API call. Same idea as pipeline-aware content variants — encoding differentiation at build time rather than per request. There are 18 take files today against 80 product pages, so most pages don't have one.
The configuration, as written on day one
apps/oss-alternatives/src/content.config.ts, trimmed to the parts that matter:
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
import { fileURLToPath } from "node:url";
// Resolve the base directory relative to this config file (not cwd), otherwise
// `astro build` from a workspace root mis-resolves the path and silently skips
// the collection.
const TAKES_DIR = fileURLToPath(new URL("./content/per-alternative-takes", import.meta.url));
const perAlternativeTakes = defineCollection({
loader: glob({
pattern: "**/*.md",
base: TAKES_DIR,
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 };
import.meta.url is the URL of the current module — always absolute, always pointing at the config file regardless of where the build was invoked. new URL("./content/...", import.meta.url) resolves the relative segment against that module URL, and fileURLToPath() converts the result to an OS path string. The resolved path is <repo>/apps/oss-alternatives/src/content/per-alternative-takes, from any working directory.
That comment in the source is what I believed at the time. It's worth checking beliefs like that against the code.
What the loader actually does with a relative base
Reading astro/dist/content/loaders/glob.js in 5.18.1 — the version this app is pinned to — the relevant lines are:
const baseDir = globOptions.base ? new URL(globOptions.base, config.root) : config.root;
// ...
const exists = existsSync(baseDir);
if (!exists) {
logger.warn(`The base directory "${fileURLToPath(baseDir)}" does not exist.`);
}
Two corrections to the folklore, mine included:
It resolves against config.root, not process.cwd(). The Astro project root is the anchor, so a relative base is stable with respect to the config file as long as the root is the app directory — which it normally is.
It isn't silent. A missing base directory produces a build warning naming the exact resolved path. That's a genuinely useful message; the failure mode is that it's a warn, not an error, so the build still exits zero and a matrix build across three apps will bury it in a few hundred lines of output.
I also checked the assumption underneath the comment. pnpm --filter @seo-farm/oss-alternatives build does not run from the repository root — pnpm runs a package's script with the working directory set to that package, which pnpm --filter @seo-farm/oss-alternatives exec pwd confirms is apps/oss-alternatives. And the CI workflow sets working-directory: apps/${{ matrix.app }} explicitly anyway. So the specific scenario I wrote the guard for doesn't arise in this repo.
Why I keep the absolute-path habit anyway
Three reasons, none of which require the bug to have bitten me:
-
config.rootis not always the config file's directory. Pass--root, or let an orchestrator invoke Astro with a root above the app, and a relativebasefollows the root while the file it points at doesn't move.import.meta.urlis immune by construction. - A warning that doesn't fail the build is one grep away from being invisible. "Exits zero, ships fewer pages" is the worst class of wrong, and the absolute path removes the possibility rather than relying on me reading the log.
-
It costs two lines.
fileURLToPath(new URL(..., import.meta.url))is the standard ESM replacement for__dirname, and Astro config files are always ES modules.
My working rule: any path in an Astro config that isn't a TypeScript import statement gets anchored to import.meta.url.
generateId, and the invariant it protects
The same first version of the config also sets generateId, for a related reason. By default the glob loader derives an entry's ID from the file, but a frontmatter slug: field can override it. The take files use saas_slug: as the binding key, not slug: — but a future file copied from a template might include one.
generateId: ({ entry }) => entry.replace(/\.md$/i, ""),
Now the filename is the authoritative ID and frontmatter can't override it. The page component leans on that:
const matchingTakes = allTakes.filter(
(t) => t.id === saas.slug && t.data.saas_slug === saas.slug,
);
Both the filename and the frontmatter have to agree with the page's slug before a take is attached. To make a mismatch loud rather than quiet, alternatives/[slug].astro throws explicitly — this is a hand-written check in the page, not something the Zod schema does, since the schema only knows that saas_slug is a string and has no idea what the file is called:
for (const t of allTakes) {
if (t.id !== t.data.saas_slug) {
throw new Error(
`per-alternative-takes: filename "${t.id}" does not match frontmatter saas_slug "${t.data.saas_slug}". Rename the file or fix the frontmatter.`,
);
}
}
A duplicate match throws too. Rename a file without updating its frontmatter and the build stops, instead of silently serving that take on the wrong page.
What I haven't built
Being honest about the gaps, because the checks I'd recommend are not ones I currently run:
No empty-collection assertion. There is nothing anywhere in the app that fails when getCollection("per-alternative-takes") returns zero entries. A dev-only guard in the page component would be about six lines:
if (import.meta.env.DEV) {
const all = await getCollection("per-alternative-takes");
if (all.length === 0) throw new Error("per-alternative-takes is empty — check the glob base path");
}
I've written it here; I haven't added it to the repo.
No post-deploy content assertion. The post-deploy checks that actually run after a Cloudflare Pages build validate JSON-LD (scripts/audit-jsonld.mjs). Fetching a page where I know a take exists and asserting the editorial section appears in the HTML is the obvious extension, and it's the one that would catch a whole category of "built fine, shipped less content" regressions. Not written yet.
The gap between "I know what check I want" and "the check exists" is where most of my silent failures live.
The broader pattern: Astro defaults assume single-app builds
The other place I've hit Astro behaviour that differs by context was @astrojs/sitemap generating /sitemap-0.xml instead of /sitemap-index.xml on small sites — output that depended on total page count rather than on anything I'd configured.
Both cases share a shape: the default is tuned for a single app built from its own directory, the behaviour that changes is implicit, and the build still exits zero. The content quality gate I built for articles is the same principle applied to prose — check the assumption rather than trust that silence means success.
The E-EAT transparency work added a methodology page documenting content sourcing, and the editorial takes sit above the programmatic three-tier content quality ladder. Both are worth nothing if the collection quietly returns empty and no take ever renders.
FAQ
Does the glob loader throw if the base path doesn't exist?
No — in Astro 5.18.1 it calls logger.warn with the resolved path and continues with zero entries. So you do get a message, but the build succeeds. I'd still prefer an explicit glob({ base, required: true }) that fails the build; as of writing there's no such option.
Does a relative base resolve from process.cwd()?
Not directly. It resolves against config.root (new URL(globOptions.base, config.root)). Those coincide in the common case of running astro build inside the app directory, which is probably where the cwd folklore comes from.
Does this affect all Astro loaders or just glob?
I checked glob. The file loader takes a single path and is more likely to surface something on a missing file, though I haven't traced every failure mode. Custom loaders resolve paths however their author wrote them.
Can I use process.cwd() instead of import.meta.url?
Don't. process.cwd() describes where you invoked the build, not where the file lives — it's strictly worse than the relative path it would replace. Use import.meta.url.
Does pnpm --filter <pkg> build run from the repo root?
No. pnpm sets the working directory to the package when running its script; pnpm --filter @seo-farm/oss-alternatives exec pwd prints the app directory. If you've read otherwise (I had), it's worth confirming in your own workspace before designing around it.
Related: Astro 5 content collections as an editorial layer | Pipeline-aware content variants in a static Astro directory
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)