Website Writing Ergonomics
Perenially a work-in-progress. Notes below are for future reference, not intended as advice.
The Goal: A Living Notebook
This site is meant to be regularly updated as a personal notebook where I can:
- Jot down quick notes
- Draft and revise writing
- Add book notes as I'm reading
- Update content without friction
For this to work, I want to write in my code editor and see changes immediately when I refresh the page.
Why MDX?
I chose MDX because it lets me write in plain markdown while having access to custom React components with JSX as needed.
But here's the challenge: how do you actually render MDX content in a React app?
You can parse MDX at runtime, which is slow and adds bundle size, or at build time, which is fast but requires deciding what format to store the parsed content in.
This decision affects performance, maintainability, and how easy it is to keep updating the site.
The Rendering Problem
When I run npm run dev, a build script processes all my MDX files and generates TypeScript files that React can import. The question is: what format should these generated files use?
Option A: HTML strings โ what I tried first
- Parse markdown โ output HTML:
"<h1>Hello</h1><p>This is <strong>bold</strong></p>" - Store HTML strings in generated files
- React component converts HTML back into React elements using browser's
DOMParser - Problem:
DOMParseronly exists in browsers, not during server-side rendering - Workaround:
typeof windowchecks โ causes hydration mismatches โ bugs
Option B: Structured data โ current approach
- Parse markdown โ output structured nodes:
[{ type: "heading", level: 1, children: "Hello" }, ...] - Store data structures in generated files
- React component directly maps nodes to React elements
- Works everywhere: SSR, client, no special cases
How It Works Now
The flow:
1. I write: content/notes/my-note.mdx
2. Build script runs: npm run dev or npm run build
3. Parser reads MDX โ converts to structured data
4. Generated file: content/notes.tsx with typed data structures
5. React imports and renders: no parsing needed, just map data โ JSXBefore โ HTML strings:
MDX โ HTML string โ DOMParser [browser only!] โ ReactAfter โ structured data:
MDX โ structured nodes โ React [works everywhere]Type Definitions
Every piece of content is now a typed node:
type ContentNode =
| HeadingNode
| ParagraphNode
| ListNode
| PolaroidNode
// ... etc
interface HeadingNode {
type: "heading"
level: 1 | 2 | 3 | 4 | 5 | 6
children: InlineContent
}
interface ParagraphNode {
type: "paragraph"
children: InlineContent
}Inline content (bold, links, code) nests recursively:
type InlineContent = string | InlineNode | (string | InlineNode)[]
type InlineNode =
| LinkNode
| StrongNode
| EmphasisNode
| CodeNode
// ... etcExample Transformation
Input MDX:
### Package Managers
A **package** is reusable code. Learn more [here](https://example.com).Old output (HTML string):
{
"content": "<h3>Package Managers</h3>\n<p>A <strong>package</strong> is reusable code. Learn more <a href=\"https://example.com\">here</a>.</p>"
}New output (structured nodes):
{
"content": [
{
"type": "heading",
"level": 3,
"children": "Package Managers"
},
{
"type": "paragraph",
"children": [
"A ",
{ "type": "strong", "children": "package" },
" is reusable code. Learn more ",
{ "type": "link", "url": "https://example.com", "children": "here" },
"."
]
}
]
}The Renderer
Old renderer โ client-only:
// Uses DOMParser - doesn't work in SSR
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')New renderer โ universal:
function renderNode(node: ContentNode) {
switch (node.type) {
case "heading":
const Tag = `h${node.level}`
return <Tag>{renderInline(node.children)}</Tag>
case "paragraph":
return <p>{renderInline(node.children)}</p>
// ... etc
}
}No parsing or typeof window checks, just React.
Why This Matters for Maintenance
This approach makes it easier to keep updating the site:
- Type safety - Adding new component types -> TypeScript catches missing cases at build time, instead of runtime errors
- SSR compatible - No
DOMParserworkarounds, no hydration bugs - Debuggable - When something breaks, I can inspect clean data structures instead of HTML strings
- Extensible - Adding new markdown features like polaroids means updating the parser and renderer in predictable places
- Fast feedback loop -
npm run devautomatically regenerates content. I write MDX, refresh browser, see changes immediately
Tradeoffs
- Larger generated files - Structured JSON is more verbose than HTML, but compresses well with gzip
- Parser complexity - Tracking nested inline elements like bold inside links requires careful bookkeeping
- Migration cost - Had to rewrite the parser and regenerate all existing content
Files Changed
types/content-ast.ts- Type definitions for all node typeslib/markdown-parser.ts- TypeScript version of the parserscripts/generate-content.mjs- Build script with embedded parsercomponents/content-renderer.tsx- New renderer that maps nodes to Reactcontent/*.tsx- All generated files now exportContentNode[]