Skip to content
Shenzhen · The Greater Bay Area · Earth

MDX Content Architecture: A Malformed Entry Should Fail the Build, Not the Page

A post with an empty description field renders correctly, indexes, and stays mute. Colocating MDX with the code that renders it and validating frontmatter at parse time turns that invisible defect into a failed build, and the same parsed object then feeds the page, the metadata, the JSON-LD graph, the sitemap and the feed.

8 min read1,706 words
Next.js ArchitectureSystemsNot yet translated.

Why should a content mistake stop a deploy?

MDX content architecture reduces to one decision: where the frontmatter contract is enforced. I keep content in the same repository as the code that renders it, parse every entry into a typed object at build time, and let a malformed entry stop the build, because the alternative is a page that ships with an empty description field, renders correctly, and is invisible in every surface that summarises rather than displays.

The failure mode is why this is worth writing down. A post with no description has a title, headings, a canonical URL, working navigation and a valid document. Nothing throws. No route returns a 500, no warning appears in a console nobody reads, and no uptime monitor notices. The page is live, indexable and mute. I have watched that state persist for months on sites where the field is entered through a form that treats it as optional, and the person who could have fixed it in ten seconds had no signal that anything was wrong.

Content colocated with the code that renders it and validated at build time converts a class of invisible production defects into a build error, and a build error is the cheapest failure a content pipeline can have.

What does colocation actually mean here?

Concretely, it means one directory tree and three rules:

$ tree content/blog -L 2
content/blog
├── en
   ├── ai-adoption
   ├── ai-systems
   ├── design-engineering
   ├── engineering
   └── ...
└── zh
    ├── ai-systems
    └── design-engineering

Every entry sits at content/blog/<locale>/<category>/<slug>.mdx. The first path segment is the locale, so a missing translation is a missing file rather than a boolean that can drift out of sync with reality. The second is the category, and it is free-form: the folder name is slugified into the category id, so a folder called AI Systems produces ai-systems, and adding a category means creating a folder. Adding an article means dropping a file. There is no registry entry, no config change and no code edit, which matters more than it sounds, because a publishing flow that requires an engineer to merge a config diff is a flow that accumulates a backlog of unpublished writing.

The third rule is the one I would defend hardest: the slug is the bare filename, not the path relative to the locale root. This site currently holds 57 English entries across seven category folders, and posts get re-filed as the categories settle. If the slug were derived from the path, every re-filing would change a public URL, invalidate inbound links, and reset whatever standing that URL had accumulated. Keeping the slug independent of the folder keeps categorisation a free operation, which is the only reason it actually happens instead of being deferred indefinitely.

Where does the type boundary sit?

At one function, sitting between bytes and the application. The frontmatter parser splits the YAML block from the body and hands back an untyped record for the first half, and a YAML block guarantees nothing: tags may be a string, date may already have been coerced into a Date object, and readingTime may be absent entirely. So the parse step narrows that shape and refuses anything it cannot narrow:

import type { Locale } from '@/lib/i18n';

export interface PostFrontmatter {
  title: string;
  description: string;
  date: string;
  excerpt: string;
  tags: string[];
  readingTime: number;
  lang: Locale;
}

const REQUIRED = ['title', 'description', 'date', 'excerpt', 'tags', 'readingTime'] as const;
const DESCRIPTION_MAX = 200;

export function parseFrontmatter(file: string, raw: Record<string, unknown>): PostFrontmatter {
  const fail = (message: string): never => {
    throw new Error(`[content] ${file}: ${message}`);
  };

  for (const field of REQUIRED) {
    if (raw[field] === undefined || raw[field] === null || raw[field] === '') {
      fail(`missing required frontmatter field "${field}"`);
    }
  }

  const date = String(raw.date);
  if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) fail(`"date" must be ISO, received "${date}"`);

  const description = String(raw.description);
  if (description.length > DESCRIPTION_MAX) {
    fail(`description is ${description.length} chars, over the ${DESCRIPTION_MAX} ceiling`);
  }

  return {
    title: String(raw.title),
    description,
    date,
    excerpt: String(raw.excerpt),
    tags: (raw.tags as unknown[]).map(String),
    readingTime: Number(raw.readingTime),
    lang: raw.lang as Locale,
  };
}

Two details carry the design. First, the never return type on fail: it lets the helper sit in expression position on one line and in statement position inside a loop, and it documents that every path either produces a complete PostFrontmatter or terminates the build. Second, the String() and Number() conversions are not defensive noise. They are what makes the declared return type honest when the input is unknown, and they push the coercion into a single place instead of scattering it across five consumers that each assume their own version of the shape.

The ceiling is a hard failure at 200 characters, and the 110 to 168 band below it is a warning rather than an error, printed with the file path. Length is a quality signal and failing a build over a slightly short description would be pedantic, but a 214-character description is guaranteed to be cut mid-sentence in a result snippet, so it is worth stopping.

Why is a single parse point worth engineering for?

Because five consumers read the same entry during one build: the article page, the metadata function, the JSON-LD graph builder, the sitemap and the RSS feed. If each parses the file itself, they can disagree, and the disagreements are invisible. A description that is 160 characters in the feed and 200 in the metadata is not a defect anyone files. Parsing once behind a memoising wrapper settles the correctness question and the cost question together: one read per file per build instead of five, which is the difference between a build you wait for and a build you stop noticing.

import { cache } from 'react';

interface Post extends PostFrontmatter {
  slug: string;
  category: string;
  body: string;
}

/** Words divided by 220, floored at one minute. Memoised for the build. */
const measuredReadingTime = cache((body: string): number =>
  Math.max(1, Math.round(countWords(body) / 220)),
);

const readAllPosts = cache((): Post[] => {
  const posts: Post[] = [];

  for (const file of locateMdxFiles('content/blog')) {
    const { data, content } = matter(readFileSync(file, 'utf8'));
    const frontmatter = parseFrontmatter(file, data as Record<string, unknown>);
    const [locale, category] = localeAndCategory(file);
    const body = content.trim();

    posts.push({
      ...frontmatter,
      slug: basename(file).replace(/\.mdx$/, ''),
      category,
      body,
      // The measured value replaces the declared one. Every consumer reads this
      // number and no other, so the page and the structured data cannot drift.
      readingTime: measuredReadingTime(body),
    });
  }

  return posts.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
});

The readingTime replacement is the part I would point at if someone asked whether the typing is doing real work. A declared reading time is a human guess made on the day the post was written, and it decays the moment the post is edited. Measuring from the body at the single entry point means the number on the page and the timeRequired value in the structured data are the same number by construction rather than by discipline. The date sort is the second: ISO strings compare lexically the same way they compare chronologically, so the ordering needs no parser and cannot disagree with the display format.

What does the build catch when an entry is wrong?

Not everything, but more than a form validation layer typically does, and the failures arrive before a deploy rather than after it:

$ pnpm build
Creating an optimized production build ...

Failed to compile.

Error: [content] content/blog/en/engineering/cache-invalidation-notes.mdx: missing
required frontmatter field "description"
Error: [content] content/blog/en/engineering/render-budget-notes.mdx: description is
214 chars, over the 200 ceiling
Error: [content] content/blog/en/engineering/example-entry.mdx: "date" must be ISO,
received "3 March 2026"
    at parseFrontmatter (lib/mdx.ts:71:11)
[exit code: 1]

The file path is in every message on purpose. A build that fails with "missing required frontmatter field" and no filename costs more time to act on than the check cost to write.

Mistake in an entryWhat a form-validated CMS usually doesWhat the build-time check doesWhere the defect would have surfaced
Missing descriptionAccepts it, field is optionalThrows, build failsA summariser invents the sentence, or shows the first line of the body
Description over 200 charactersAccepts it, truncated at renderThrows, build failsSnippet cut mid-sentence in every result
Date not ISOAccepts it, coerces inconsistentlyThrows, build failsPost sorts to the end of the index, archive order goes stale
Tag outside the allowed setAccepts it, creates a new tagFlags the entry, since frontmatter tags are free strings; a tag the union does not know fails at compile timeA category page with one entry and a dead filter
Slug colliding with an existing fileDepends on the CMS keyFails on the duplicate routeOne page silently replaces another in the sitemap
Image referenced but never committedAccepts the referenceFails a link check over the built outputA broken image on a live page

The honest limit of the table is its last column. Every one of these is a defect that would have to be discovered by a person looking at the right page at the right moment, and the build gate replaces that with a machine reading every entry on every commit. That is not a stronger opinion about content. It is a cheaper place to be wrong.

When is colocation the wrong answer?

It is the wrong answer as soon as the people writing are not in the repository, which is most organisations above a certain size. If authors need a preview they can share with someone who has no checkout, if publication is scheduled to the minute for a campaign, if every entry needs a legal approval step recorded against a user account, or if marketing wants to fix a typo at 23:00 without waiting for CI, then a repository-backed pipeline is fighting the workflow it is supposed to serve. Git history is a fine audit log and a terrible editorial interface.

It is also the wrong answer for scale in a specific direction. Validation over 57 files costs milliseconds, but the whole approach reads the content tree on every build, so a corpus of tens of thousands of entries with cross-references will need an index and an incremental path that the plain version does not have. And it is the wrong answer when content is genuinely relational: pricing tables joined against a product catalogue, translations tracked per-locale with their own review states, or entries that several products consume through an API. Once content is a database, model it as one.

The more common mistake is subtler than picking the wrong tool. It is colocating content but validating nothing, which gives you all of the downsides of a repository, no editorial interface and a deploy for every edit, without the one property that justifies them.

How does the same typed object reach a search or answer engine?

A validated description is not a formatting concern. It is the sentence a retrieval system lifts verbatim when it summarises the page, which is exactly why its absence is a hard build failure rather than a lint warning and why its length is capped instead of merely reported. The same parsed fields then become the entity facts, and the discipline of keeping one object as the source is what makes those facts match the rendered page instead of approximating it; how the entity graph makes those same frontmatter facts machine-readable is the other half of this argument, and it depends entirely on there being one version of the truth to publish.

What should you do before moving any content?

Add the check to your existing entry point first, before you migrate a single file, and let it run against the content you already have: a list of required fields, a date format test, a length ceiling, and a throw that names the offending file and field, wired into whatever already reads your content during a build. Run it once and expect it to fail on entries nobody knew were broken, because an optional field in an editorial form is a field that is missing somewhere. Fix those entries, commit the check, and the gate exists from then on without anyone having to remember it. What you cannot automate is whether the description is true and specific, so the build guarantees that a sentence exists and fits, and a human still has to decide whether it deserves to be read.

Keep reading

More in Engineering

Ready to build a system?[ Book a Call ]