This site has two blog collections. Not one collection with an en and es field on every post: two separate defineCollection calls, log and logEs, each scanning its own directory tree, each with its own Zod schema instance, each rendered by its own route file. If you go looking in src/content.config.ts you’ll find this:
const log = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/log" }),
schema: logSchema,
});
const logEs = defineCollection({
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/log-es" }),
schema: logSchema,
});
export const collections = { log, logEs };
Same schema function, logSchema, called twice to produce two independently-typed collections. Nothing links an entry in log to its counterpart in logEs except a filesystem convention: they happen to live at the same slug, in sibling directories (src/content/log/<slug>/index.mdx and src/content/log-es/<slug>/index.mdx). Astro’s content layer has no idea a relationship exists.
That’s a deliberate shape, not an oversight, and it’s worth writing down why: the obvious alternative (one collection, a translations object, a single source of truth per post) is genuinely more elegant on paper and genuinely wrong for how this particular site gets written.
The shape and why it’s not obviously right
A single-collection design would look something like a schema where each entry carries a content field keyed by locale (title.en, title.es, body.en, body.es), or, more commonly in the Astro world, a schema with a translations map and one canonical loader. Either way, the collection is now the unit of translation-completeness, and it becomes structurally impossible to publish an English post without at least a schema-level slot for its Spanish counterpart.
That sounds like a feature. For UI copy (nav labels, button text, page chrome) it is a feature, and this codebase does that: every i18n namespace lives in src/i18n/dictionaries/<namespace>.ts, and src/i18n/index.ts type-checks the Spanish export against the English one with satisfies Widen<typeof en>. Miss a key on either side and pnpm check fails the build. There’s no export const es = en; escape hatch. That’s the right contract for a fixed, small set of strings that must always exist in both languages because every page depends on all of them.
A blog post is not that. A blog post is a long-form, occasionally-updated, occasionally-abandoned piece of writing, and the two language versions of it are not the same artifact translated: they’re two artifacts that happen to cover the same ground. The English version of a post might get a follow-up paragraph six months later that never makes it into the Spanish version, because nobody got around to it, and that’s fine. It’s not a bug in the content model, it’s just how translation work gets prioritized when there’s one person doing it in spare time.
Two loaders, two route files, no cross-collection glue
The log collection is rendered by src/pages/log/[slug].astro; the logEs collection by src/pages/es/log/[slug].astro. Both files are close to identical (same getStaticPaths, same render() call, same layout) with the one substantive difference sitting in the collection name passed to getCollection:
// src/pages/log/[slug].astro
export async function getStaticPaths() {
const posts = await getCollection("log", ({ data }) => !data.draft);
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}
// src/pages/es/log/[slug].astro
export async function getStaticPaths() {
const posts = await getCollection("logEs", ({ data }) => !data.draft);
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
}
Each route only ever knows about its own collection. There’s no step anywhere in the build where the English route asks “does a Spanish version of this slug exist, and should I link to it.” If that link ever gets built (a “read this in Spanish” toggle on a post page) it’ll be an explicit lookup against logEs by slug, done deliberately, not something the content model hands you for free. Right now it doesn’t exist at all, and nothing in the schema or the loaders forces it to.
This is the same per-locale duplication that shows up everywhere else in the site’s routing, not something unique to the blog — see Every Page Is Its Own File for why about.astro and es/about.astro are separate, near-identical files for the same underlying reason log and logEs are separate, near-identical collections.
There’s a smaller but very concrete reason the two collections live in separate directory trees rather than as index.mdx / index.es.mdx siblings in one folder: Astro’s glob loader collapses a bare index filename into its parent folder’s slug. Two index.* variants sitting in the same directory would either collide on that collapse or need a different, inconsistent slug-derivation rule for the suffixed one. Keeping log-es as a fully separate tree, mirroring log’s folder-per-slug layout, means the exact same loader configuration and the exact same slug logic apply to both: the only thing that changes between the two defineCollection calls is the base path.
The split shows up again in “related posts,” not just routing
The independence isn’t confined to the two route files — it propagates into every component that has to decide “which posts are the candidates here.” RelatedPosts.astro, the component both post pages render at the bottom, is a good example because it makes the same decision the route files make, in miniature:
const collectionName = lang === "es" ? "logEs" : "log";
const all = await getCollection(
collectionName as "log",
({ data }) => !data.draft,
);
const related = all
.filter((p) => p.id !== post.id && p.data.category === post.data.category)
.sort((a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf())
.slice(0, 3);
An English post’s “more in this category” list is built exclusively from getCollection("log"): it can never surface a Spanish post, even one on the identical topic, even one that would arguably be the single most relevant related read if the two collections shared a namespace. That’s not a bug in RelatedPosts.astro; it’s the same independence the content model establishes, showing up correctly one layer up. The component isn’t filtering out cross-locale posts as a special case — it never has the option to see them in the first place, because lang picks exactly one collection to query and that’s the entire candidate pool.
This is worth naming because it’s the kind of consequence that’s easy to miss when reasoning about the two-collection split only at the schema level. The split isn’t just “two loaders instead of one”: it’s a decision that ripples into every downstream query that has to pick a set of posts to work with: related-posts lists, category directories, the log index page’s pagination, the JSON-LD breadcrumb and BlogPosting schema builders that take a lang parameter and never see the other collection at all. Every one of those call sites independently re-derives “which collection am I working with” from the current locale, and every one of them, as a consequence, treats the other language’s posts as if they didn’t exist. That’s consistent with the model, not a leak in it, but it’s a real, compounding cost of choosing two collections over one, and it’s worth knowing it extends past the content schema before deciding the trade is worth it for a given site.
What this gives up
The honest cost: there is no build-time signal that an English post is missing its Spanish translation, or vice versa. If someone deletes src/content/log-es/some-slug/index.mdx by accident, pnpm check will not complain. The Spanish /log index page will just quietly list one fewer post, and nothing turns red. A translation-parity check, if this site ever wants one, would have to be a separate script — walk both trees, diff the slug sets, fail CI on a mismatch — because the content collection schema genuinely cannot express “this English entry requires a matching Spanish entry” without either collapsing back into one collection or adding a bespoke cross-collection validation step that content collections aren’t designed for.
That’s a real gap. For UI dictionaries it would be an unacceptable one, which is exactly why that part of the codebase enforces parity at the type level instead. For long-form posts it’s a gap this site is choosing to live with, because the alternative, blocking an English post from shipping until its Spanish translation exists, or vice versa, would slow down the thing that matters here, which is publishing.
What it buys: an English-only post costs zero Spanish scaffolding
Here’s the concrete case, and it’s not hypothetical: it’s this exact batch of work. Twenty new Engineering Log posts are going into src/content/log/ as part of this pass, all in English, and the instructions for writing them are explicit: don’t touch src/content/log-es/ at all, nothing needed there. That instruction is only sane because the two collections are independent.
If this were one collection with an es slot required by the schema, adding twenty English posts would mean the schema either rejects them outright (missing required field) or accepts them with twenty empty/placeholder Spanish entries sitting in the content tree, which is worse than no Spanish version, because now there’s a stub that looks like content but isn’t. Neither option is “just write the English post.”
With two independent collections, the English side of the blog can grow at whatever pace English writing happens, and the Spanish side grows separately, later, by whoever does that translation pass, against whichever subset of slugs they choose to cover — possibly all twenty, possibly three, possibly none for a while. Both are valid states of the content tree. Nothing needs migrating, nothing needs a placeholder, and pnpm check stays green through all of it, because green was never conditional on the two collections agreeing with each other in the first place.
The trade only makes sense in the direction it’s actually being used here: this is a personal site with one primary author, one primary language for drafting, and translation as a genuinely separate, lower-frequency pass. A team producing both languages in lockstep from day one, with a translator on staff and a publish-both-or-neither policy, would be better served by the single-collection model and its build-time parity guarantee. Different content, different cadence, different right answer: the two-collections split isn’t a universally correct pattern, it’s the correct pattern for how this specific blog gets written.