Every portfolio site eventually faces the same question: where does the content live? The fashionable answers — Contentlayer, a headless CMS, some MDX bundler — all couple your content model to someone else's roadmap. Contentlayer is effectively unmaintained. CMSes add a network dependency to a site that could be fully static. I wanted a third option: own the thin layer.
The contract comes first
The trick is to define the interface before the implementation:
export interface ArticleRepository {
getAll(filter?: ArticleFilter): Promise<Article[]>;
getBySlug(slug: string): Promise<Article | null>;
getSlugs(): Promise<string[]>;
}Pages import repositories.articles and nothing else. They don't know
about frontmatter, file paths, or fs. That single decision means the
storage engine is swappable — today it's MDX files parsed with
gray-matter, tomorrow it can be Postgres rows behind the same interface.
Zod is the gatekeeper
Every frontmatter block is validated at build time:
const parsed = articleFrontmatterSchema.safeParse(data);
if (!parsed.success) {
throw new Error(`Invalid frontmatter in ${file}: …`);
}A typo in a date or a missing summary fails next build with a precise
error. Malformed content cannot reach production, and the Zod types double
as the future database row types.
What this costs
About two hundred lines, written once. What it buys: no framework churn, a data model I can explain in an interview, and a migration path where the UI never changes. Sometimes the senior move is writing less clever code.