Building a Markdown Renderer: Lessons from the Trenches

By Claude Sonnet 4.5*Claude wrote this doc to summarize the markdown support implementation workΒ·Β  January 20, 2026
docsProject: jaygriffFeature: Markdown Support
🏷️ Tags:markdownreactdebuggingimplementationreact-markdown

How we built a markdown rendering system with react-markdown, and all the edge cases that broke along the way

The Architecture: TSX-First, MD-Fallback

We didn't want to replace TSX content files. TSX gives us:

But markdown has advantages too:

Solution: Support both. Check for TSX first, fall back to markdown if it doesn't exist. Same slug, different file extensions.

File Discovery

The getAllPosts() and getAllDocs() functions scan both directories:

.ts
// Scan content/tsx/ for TSX files
const tsxPosts = await Promise.all(
  tsxFilenames
    .filter((filename) => filename.endsWith('.tsx'))
    .map(async (filename) => {
      const module = await import(`@content/tsx/${filename.replace('.tsx', '')}`);
      return {
        filename: filename.replace('.tsx', ''),
        metadata: module.metadata as PostMeta,
      };
    })
);

// Scan content/md/ for markdown files
const mdPosts = mdFilenames
  .filter((filename) => filename.endsWith('.md'))
  .map((filename) => {
    const filePath = path.join(mdDirectory, filename);
    const fileContent = fs.readFileSync(filePath, 'utf-8');
    const { metadata } = parseMarkdownWithJsonFrontmatter(fileContent);
    return {
      filename: filename.replace('.md', ''),
      metadata,
    };
  });

// Combine and dedupe (TSX takes priority)
const tsxSlugs = new Set(tsxPosts.map(p => p.metadata.slug));
const uniqueMdPosts = mdPosts.filter(p => !tsxSlugs.has(p.metadata.slug));
const allPosts = [...tsxPosts, ...uniqueMdPosts];

TSX always wins if both exist. This lets us migrate content incrementally.

Content Loading

The loadContentBySlug() function returns a discriminated union:

.ts
export interface LoadedContent {
  type: 'tsx' | 'markdown';
  Component?: React.ComponentType;
  markdownContent?: string;
  metadata: PostMeta;
  filename: string;
}

Page components conditionally render based on the type:

.tsx
{content.type === 'tsx' && content.Component ? (
  <content.Component />
) : content.type === 'markdown' && content.markdownContent ? (
  <MarkdownRenderer content={content.markdownContent} />
) : null}

Problem 1: Inline Code vs Block Code

The Bug: Inline code like <span> was rendering as full code blocks.

The Cause: Our detection logic was broken. We were checking props.className !== undefined, but react-markdown sets className to undefined for inline code too.

The Fix: Check if className actually exists as a string:

.tsx
code: ({ children, ...props }: any) => {
  // Block code has a className with content (like 'language-tsx')
  // Inline code will not have a className
  const isBlock = props.className && typeof props.className === 'string';
  if (!isBlock) {
    return <Code>{children}</Code>;
  }
  // Block code - extract language from className if present
  const language = props.className.replace('language-', '') || 'plaintext';
  return <CodeBlock language={language}>{String(children)}</CodeBlock>;
}

Only block code gets a className like language-tsx. Inline code has no className at all.

Problem 2: Horizontal Rules Weren't Styled

The Bug: Markdown --- dividers rendered as unstyled <hr> elements.

The Cause: We mapped hr to our Divider component, but the Divider had a thick colored border that didn't match the rest of the page.

The Fix: Updated Divider styling to match ContentHeader borders:

.css
/* Divider component styles */
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin: [theme.spacing.md] 0;

Subtle, consistent with the rest of the design. Perfect.

Problem 3: Lists Rendered as Walls of Text

The Bug: Markdown lists - Item 1 rendered as continuous paragraphs without bullets.

The Cause: We weren't mapping list elements to our Primitive components.

The Fix: Map ul, ol, and li:

.tsx
components={{
  ul: ({ children }) => <List>{children}</List>,
  ol: ({ children }) => <List ordered>{children}</List>,
  li: ({ children }) => <ListItem>{children}</ListItem>,
  // ... other mappings
}}

Now markdown lists use the same styled components as TSX content.

Problem 4: New Markdown Files Not Appearing

The Bug: Created a new markdown file with proper frontmatter, but it didn't show up on the homepage or have a route.

The Cause: Next.js/Turbopack caching. The dev server wasn't picking up new files automatically.

The Fix: Restart the dev server. Not elegant, but it works.

This is a limitation of Next.js's file watching system. New content files added during development require a rebuild to be discovered by generateStaticParams().


The Frontmatter Decision: JSON, Not YAML

We chose JSON frontmatter over YAML:

.md
---json
{
  "title": "Example Post",
  "slug": "example-post",
  "date": "2026-01-20T00:00:00Z",
  "author": ["Jay Griffin"],
  "type": "post",
  "tags": ["example", "markdown"]
}
---

# Content starts here

Why JSON?

The parser is dead simple:

.ts
export function parseMarkdownWithJsonFrontmatter(fileContent: string): ParsedMarkdown {
  const frontmatterRegex = /^---json\n([\s\S]+?)\n---\n([\s\S]*)$/;
  const match = fileContent.match(frontmatterRegex);

  if (!match) {
    throw new Error('No JSON frontmatter found in markdown file');
  }

  const [, jsonString, markdown] = match;
  const metadata = JSON.parse(jsonString) as PostMeta;
  
  return {
    metadata,
    content: markdown,
  };
}

Files without valid JSON frontmatter are skipped during discovery. No crashes, just silent filtering.


Component Mapping Philosophy

The MarkdownRenderer maps every markdown element to our Primitive components:

This means markdown content and TSX content use identical styling. No visual difference between the two formats.

Users can't tell if a page is TSX or markdown. Perfect consistency.


GitHub Flavored Markdown Support

We use remark-gfm for GitHub Flavored Markdown extensions:

.tsx
<ReactMarkdown
  remarkPlugins={[remarkGfm]}
  components={{
    // ... component mappings
  }}
>
  {content}
</ReactMarkdown>

Standard markdown + GFM extensions. Familiar syntax for anyone coming from GitHub.


The Result: Hybrid Content System

Now we have:

Best of both worlds. Use TSX when you need power, markdown when you need speed.

Lessons Learned

1. Test edge cases immediately. Inline code vs block code? Test it with actual examples, not just theory.

2. Check what react-markdown actually sets. Don't assume undefined means "not set." Log the props and see what's really there.

3. Map markdown to your design system. Don't let markdown elements bypass your styled components. Map everything.

4. TSX-first is the right default. For a developer-focused site, TSX gives you more power. Markdown is the convenience layer on top.

5. Next.js caching is real. New files might need a dev server restart. Document this limitation.


What's Next?

Potential improvements:

But for now? It works. TSX when we need it, markdown when we don't. Problem solved.