Writing

Long-form essays & reflections.

12-12-2025

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: DOMParser only exists in browsers, not during server-side rendering
  • Workaround: typeof window checks โ†’ 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 โ†’ JSX

Before โ€” HTML strings:

MDX โ†’ HTML string โ†’ DOMParser [browser only!] โ†’ React

After โ€” 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
  // ... etc

Example 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 DOMParser workarounds, 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 dev automatically 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 types
  • lib/markdown-parser.ts - TypeScript version of the parser
  • scripts/generate-content.mjs - Build script with embedded parser
  • components/content-renderer.tsx - New renderer that maps nodes to React
  • content/*.tsx - All generated files now export ContentNode[]

Further Reading