Skip to main content

Killing Split-Brain Content with One Metadata Export

Apr 11, 20263 min readNext.js, MDX, TypeScript, Jest

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 was not the source of truth for what 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). OG image generators read from the thumbnail records too.

That is five files duplicating what MDX already knew. And duplication drifts. After 47 entries, several listing cards showed different titles or descriptions than the actual post. The thumbnails said one thing; 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.

I extended PostMetadata with the fields that previously only lived in 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. Listing cards, OG images, and pagination all read from the same derived shape. There is no second copy of the data.

Collapsing Structured Data

The structured data files were the most dramatic reduction. Blog structured data went from 300 lines of hand-authored per-slug records 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 to the same pattern. Adding a new blog post or project no longer requires a structured data entry: 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 does not understand MDX out of the box. Turbopack handles it in dev; webpack handles it in production builds. Tests had neither.

I wrote a minimal Jest transformer (__mocks__/mdxTransform.js) that parses the export const metadata block from MDX source and returns it as a JavaScript module. The transformer does not render MDX content; it 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 accumulated first-person plural ("we built," "our library") from early drafts when the voice was 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. It included 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.ts from 300 to 46 lines; structuredData/project.ts from 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 is not just a cleanup; it changes the failure mode. With one source, incorrect metadata is visible everywhere immediately. With two, it hides in the gap between them.