The Problem
This portfolio has 47 MDX content files across three types: blog posts, project case studies, and experience entries. Each file already exported a metadata object with a title, excerpt, date, tags, and slug, but that metadata wasn't the source of truth for what actually appeared on the site.
Listing-page cards pulled titles and descriptions from separate *Thumbnails.ts files: blogThumbnails.ts (430 lines), projectThumbnails.ts (161 lines), and experienceThumbnails.ts (96 lines). Structured data for blog and project pages lived in hand-authored records inside structuredData/blog.ts (300 lines) and structuredData/project.ts (104 lines), and the OG image generators read from the thumbnail records too.
That's five files duplicating what the MDX already knew, and duplication drifts. After 47 entries, several listing cards were showing different titles or descriptions than the actual post: the thumbnails said one thing and the MDX said another. Nobody noticed because the data never ran through a single codepath.
The Fix: Extend MDX Metadata, Delete Everything Else
The approach was straightforward: make the MDX export const metadata block carry every field the site needs, then derive everything else from it at registry-build time.
So I extended PostMetadata with the fields that had previously only lived in the thumbnail records:
export interface PostMetadata {
title: string;
date: string;
excerpt: string;
author: string;
category: string;
tags: string[];
slug: string;
type: ContentType | (string & {});
cover: UnsplashImageMeta; // new
company?: string;
role?: string;
duration?: string;
industry?: string;
featured?: boolean; // new
logo?: string; // new (experience)
invert?: boolean; // new (experience)
domain?: string; // new (experience)
}Then I wrote buildThumbnail(), a 27-line function that derives the PostThumbnail shape from metadata at registry-build time:
export function buildThumbnail(
metadata: PostMetadata,
readingTime: number
): PostThumbnail {
const basePath = contentTypeConfigs[metadata.type as ContentType].basePath;
const thumbnail: PostThumbnail = {
slug: metadata.slug,
title: metadata.title,
description: metadata.excerpt,
cover: metadata.cover,
link: { href: `${basePath}/${metadata.slug}` },
readingTime,
};
if (metadata.role !== undefined) thumbnail.role = metadata.role;
if (metadata.duration !== undefined) thumbnail.duration = metadata.duration;
if (metadata.featured !== undefined) thumbnail.featured = metadata.featured;
return thumbnail;
}The content registry calls buildThumbnail() for every entry during module initialization, so listing cards, OG images, and pagination all read from the same derived shape. There's no second copy of the data to keep in sync.
Collapsing Structured Data
The structured data files gave me the most dramatic reduction. Blog structured data went from 300 lines of hand-authored per-slug records down to a programmatic generator:
export const blogStructuredData = Object.fromEntries(
blogPageSlugs.map(slug => {
const meta = blogMdxMetadata[slug];
return [
slug,
{
'@context': 'https://schema.org',
'@type': 'Article',
headline: meta.title,
description: meta.excerpt,
datePublished: meta.date,
author,
},
];
})
) as Record<AllowedBlogSlugs, BlogStructuredData>;Project structured data collapsed from 104 lines into the same pattern. Adding a new blog post or project no longer needs a structured data entry at all; the generator picks it up automatically from the MDX metadata.
Testing MDX Imports in Jest
One complication: the content registry imports .mdx files to read their metadata, but Jest doesn't understand MDX out of the box. Turbopack handles it in dev and webpack handles it in production builds, but the tests had neither.
So I wrote a minimal Jest transformer (__mocks__/mdxTransform.js) that parses the export const metadata block out of the MDX source and returns it as a JavaScript module. It doesn't render any MDX content; it just extracts the metadata export so registry tests can verify that every slug resolves to a valid entry with the right fields.
The Voice Pass
With MDX as the single source of truth, I ran a brand-voice audit across all 47 entries. The portfolio had picked up a bunch of first-person plural ("we built," "our library") from early drafts when the voice was still undefined; I converted every instance to first-person singular, shifted project and experience excerpts to past-tense outcomes, and enforced character budgets: titles under 60 characters, excerpts under 160.
The rewrite touched 32 blog entries, 10 project case studies, and 5 experience entries, and it added up to 5 title rewrites, 19 excerpt rewrites, and over 120 body-level substitutions. Because the metadata block is now the only place titles and excerpts live, every surface picked up the new copy automatically: listing cards, OG images, structured data, and pagination links. No second file to forget.
The Numbers
- Deleted:
blogThumbnails.ts,projectThumbnails.ts,experienceThumbnails.ts(687 lines) - Collapsed:
structuredData/blog.tsfrom 300 to 46 lines;structuredData/project.tsfrom 104 to 45 lines - Added:
buildThumbnail.ts(27 lines),mdxTransform.js(61 lines) - Net: roughly 600 fewer lines of hand-maintained content data, and zero drift between what MDX says and what the site shows
The Lesson
Every duplicated record is a drift vector. If listing cards read from file A and the post reads from file B, the only question is when they diverge, not whether. Making the content file the sole authority isn't just a cleanup; it changes the failure mode. With one source, wrong metadata is wrong everywhere immediately, which is annoying but obvious. With two, it hides in the gap between them, which is how you ship a card that lies about the post it links to.
