Building a Markdown Renderer: Lessons from the Trenches
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:
- Full TypeScript type safety
- Component composition with React
- Dynamic logic and interactivity
- Direct access to our Primitive component system
But markdown has advantages too:
- Faster to write for simple content
- Familiar syntax for documentation
- Portable across platforms
- Lower barrier to entry
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:
// 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:
export interface LoadedContent {
type: 'tsx' | 'markdown';
Component?: React.ComponentType;
markdownContent?: string;
metadata: PostMeta;
filename: string;
}Page components conditionally render based on the type:
{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:
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:
/* 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:
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:
---json
{
"title": "Example Post",
"slug": "example-post",
"date": "2026-01-20T00:00:00Z",
"author": ["Jay Griffin"],
"type": "post",
"tags": ["example", "markdown"]
}
---
# Content starts hereWhy JSON?
- No external YAML parser dependency
- Native JavaScript parsing with
JSON.parse() - Easier for LLMs to generate correctly
- Type-safe with TypeScript interfaces
- Consistent with our TSX metadata exports
The parser is dead simple:
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:
h1-h4βHeadingwith levelspβParagraphul/olβListwithorderedpropliβListItemcodeβCode(inline) orCodeBlock(block)hrβDivider
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:
- Tables
- Strikethrough text
- Task lists
- Autolinked URLs
<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:
- TSX for complex content - Full React power, components, logic
- Markdown for simple content - Fast to write, easy to read
- Identical styling - Users can't tell the difference
- Slug-based routing - Files can be renamed without breaking URLs
- Type-safe metadata - JSON frontmatter parsed to PostMeta interface
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:
- Custom markdown extensions (callouts, notices, warnings)
- Syntax highlighting themes (currently using Prism defaults)
- Table of contents generation for long posts
- Hot reload for new markdown files (might require custom Next.js plugin)
- Markdown preview in development
But for now? It works. TSX when we need it, markdown when we don't. Problem solved.