The Symptom
I was reviewing blog post #1—"The 10-Minute Blog Upgrade"—when I noticed something odd. The Table of Contents showed only a handful of items. The blog had 11 sections. The TOC showed maybe 3.
This is the kind of bug that makes you question reality. The code hadn't changed. The TOC component worked fine on other posts. What was different about this one?
The Investigation
First instinct: check the TOC component. Maybe there's a filter I forgot about. Maybe there's a limit.
// table-of-contents.tsx
const headings = contentRef.current?.querySelectorAll('h2, h3') || [];
Nope. It's scanning for all H2 and H3 elements. No limits. No filters (except excluding headings inside the TOC itself to prevent recursion).
Second instinct: check the markdown. Maybe the headings are malformed.
## The WordPress Problem
## The Custom Blog Advantage
## How the Retrofit Works
...
All correct. Proper H2 syntax. IDs would be auto-generated.
Third instinct: actually look at the rendered DOM.
And there it was. Or rather, there it wasn't.
The Root Cause
This blog post uses custom visual components. Instead of rendering the markdown content directly, we render a React component:
// blog-post-new.tsx
) : blogPost.slug === "10-minute-blog-upgrade-ai-retrofit" ? (
<>
<div className="text-lg text-slate-700 leading-relaxed mb-8">
<p className="mb-6">Opening paragraph...</p>
</div>
<TenMinuteBlogUpgradeVisuals />
</>
The markdown content is never rendered to the DOM.
The visual component had its own structure—cards, diagrams, timelines—but it only used H3 headings for sub-sections. No H2 headings for main sections.
The TOC was doing exactly what it was supposed to: scanning the DOM for H2/H3 elements. It found the H3s from the visual components. It found nothing else because there was nothing else.
The Fix
The solution is embarrassingly simple: add explicit H2 headings to the visual component.
export function TenMinuteBlogUpgradeVisuals() {
return (
<>
<h2 id="the-wordpress-problem" className="text-3xl font-bold text-slate-900 mt-10 mb-4">
The WordPress Problem
</h2>
<div className="prose prose-lg max-w-none my-6">
<p>Content here...</p>
</div>
<Card1_MainStat />
<h2 id="the-custom-blog-advantage" className="text-3xl font-bold text-slate-900 mt-10 mb-4">
The Custom Blog Advantage
</h2>
{/* More content... */}
</>
);
}
Key requirements:
- Use proper H2 elements (not styled divs)
- Include
idattributes for anchor linking - Use kebab-case IDs:
id="the-wordpress-problem" - Place headings before the relevant content blocks
The Deeper Lesson
This bug reveals a fundamental tension in modern web development: abstraction vs. semantics.
React encourages component composition. We build UIs from reusable pieces. But HTML has semantic meaning. Screen readers, search engines, and yes—Table of Contents components—rely on that semantic structure.
When we replace markdown rendering with custom components, we're taking responsibility for that semantic structure. The markdown processor would have created proper H2 elements with IDs. Our custom component needs to do the same.
The Pattern
For any blog post with custom visual components:
- Identify the main sections from your markdown structure
- Add explicit H2 headings at the start of each section
- Include IDs for anchor linking
- Test the TOC after adding visuals
// Template for visual components
export function YourBlogVisuals() {
return (
<>
<h2 id="section-one" className="text-3xl font-bold text-slate-900 mt-10 mb-4">
Section One
</h2>
<YourVisualComponent />
<h2 id="section-two" className="text-3xl font-bold text-slate-900 mt-10 mb-4">
Section Two
</h2>
<AnotherVisualComponent />
</>
);
}
Documentation as Prevention
I updated our blog documentation with a new section: "⚠️ CRITICAL: Table of Contents Requirements"
The best bugs are the ones you never have to fix twice. Documentation turns a debugging session into institutional knowledge.
The Meta Reality
This bug happened on a blog post about building blog systems. I was adding features to demonstrate the power of custom components, and in doing so, broke one of the most basic features.
There's a lesson there about complexity. Every abstraction has a cost. Every custom solution requires custom maintenance. The 10-minute upgrade becomes a 30-minute debugging session when you forget the fundamentals.
But that's also the point. When you own your system, you can fix it. When you understand how it works, you can extend it. The bug was mine to create, and mine to solve.
Key Takeaway
DOM-based features require DOM-based elements. If your TOC scans for headings, your content needs headings. If your component replaces the content that would have had headings, your component needs to provide them.
Abstraction doesn't exempt you from semantics. It just makes you responsible for them.
Building JJM: The Blog
Share This Article
Spread the knowledge