Building SSR Markdown Renderers for Content Platforms
A practical guide to server-side rendering markdown: the pipeline stages, code and image handling, sanitization, and testing it like infrastructure.
The Problem Client-Side Markdown Rendering Creates
If you render markdown to HTML in the browser — fetch the raw .md, run it through a JavaScript markdown library, inject the result into the DOM — you've made a decision that quietly costs you every time something other than a modern browser with JavaScript enabled requests that page. Search crawlers that don't execute JavaScript (or execute it on a delay, or budget it stingily) see an empty shell. Social media unfurl bots building a link preview card see nothing to extract an image or description from. RSS readers, accessibility tools, and anything doing a simple HTTP fetch instead of a full browser render all see the same blank page.
Server-side rendering (SSR) of markdown means the HTML a client receives on first request is already the finished article — headings, paragraphs, code blocks, images — with no client-side markdown parsing step required to see content. It's more work at request time on your server, but it's work you control and can cache, versus a rendering dependency you're pushing onto every client that hits your site and hoping they all handle it the same way.
The Rendering Pipeline, Step by Step
A markdown-to-HTML pipeline for a content platform has more stages than "parse markdown, get HTML," even though that's the part people focus on:
- Frontmatter extraction. Most content platforms store metadata (title, publish date, author, tags) as YAML frontmatter at the top of the markdown source, separate from the body. Parse this first and validate required fields exist before touching the body — a missing publish date shouldn't surface as a rendering bug three steps later.
- Markdown parsing to an AST. Rather than going straight from markdown text to an HTML string, parse into an abstract syntax tree first (this is how the mature JavaScript markdown ecosystem — remark and its rehype counterpart for HTML — and Go's goldmark are structured, and it's a sound pattern to follow even outside those specific libraries). An AST gives you a structured place to run transformations — rewriting image paths, adding
loading="lazy"to img nodes, syntax-highlighting code blocks — before you ever serialize to a string, which is both easier to test and easier to reason about than string-manipulating HTML with regex. - Sanitization. If any part of your markdown source can come from a source you don't fully trust — user submissions, scraped content, an LLM generation step — sanitize the resulting HTML before it's stored or served. Markdown itself usually allows raw HTML passthrough, which means an unescaped
tag in source content becomes a live script tag in rendered output unless something strips it. This is a security control, not a nice-to-have, and it belongs in the pipeline, not bolted on as an afterthought in the template layer. - Serialization and caching. Once you have final HTML, cache it — at the edge, at the application layer, or both — keyed on content and template version so an edit invalidates the right cache entries without invalidating everything on the site. Re-parsing markdown on every single request is wasted CPU for content that, once published, mostly doesn't change between requests.
Handling the Parts That Aren't Plain Text
The generic case — headings and paragraphs — is the easy 80%. The remaining 20% is where SSR markdown pipelines actually differentiate themselves:
- Code blocks. Syntax highlighting needs to happen server-side too, for the same reason the rest of the rendering does — a
block with no highlighting until a client-side script runs is a flash of unstyled content at best and invisible-to-crawlers code at worst. Server-side highlighting libraries exist in every mainstream language ecosystem and integrate at the AST transformation stage described above. - Images. Markdown's native image syntax doesn't give you dimensions, and images rendered without explicit width and height attributes are a direct cause of layout shift as they load — a Core Web Vitals problem, not just a cosmetic one. If your image pipeline can determine actual dimensions at build or ingest time (reading the file, or storing dimensions alongside the asset reference), inject them as HTML attributes during the AST transform step rather than leaving it to the browser to discover at render time.
- Internal links. Markdown links written as relative paths or as full URLs to your own domain both need normalizing to a consistent internal-link format server-side, so that renamed slugs, domain migrations, or protocol changes (http to https) can be handled by rewriting logic in one place rather than depending on every piece of source content having been written a specific way.
- Embeds. Third-party embeds (video, social posts) are the hardest case, because the embed's own script often is the client-side rendering dependency you're trying to avoid elsewhere. Where possible, fetch an oEmbed response or equivalent server-side and render a static placeholder (thumbnail, title, link) that upgrades to the full embed client-side, rather than shipping a blocking third-party script as the only path to seeing any content at all.
Consistency Between Preview and Live
A subtle failure mode in SSR markdown systems is drift between the rendering path an editor sees in a preview environment and the rendering path that actually serves production traffic. If preview uses one markdown library version, one set of AST transforms, or one caching layer, and production uses another, "it looked right in preview" stops meaning anything. The fix is structural: the rendering function itself should be the same code path regardless of which environment calls it, with environment-specific behavior (draft banners, cache bypass) applied as a wrapper around that shared core rather than as a fork of the rendering logic itself.
Testing a Rendering Pipeline Like It's Infrastructure
Because this pipeline sits upstream of every article on the site, a regression in it doesn't affect one page — it affects all of them, simultaneously, the moment it deploys. That argues for treating it with the same seriousness as any other piece of shared infrastructure:
- Maintain a small fixture set of markdown documents that exercises every feature you support — nested lists, tables, code blocks in multiple languages, images, footnotes, raw HTML passthrough — and snapshot-test the rendered output against known-good HTML so a change to the parsing library or a transform step surfaces exactly what changed.
- Render a sample of real, already-published content as part of CI, not just synthetic fixtures — synthetic tests catch what you thought to test for, and real content catches what you didn't.
- Treat a rendering pipeline change as a deploy that needs the same automated post-deploy verification as anything else touching production: fetch a handful of live article URLs after the change ships and confirm the body content is still there, not just that the deploy succeeded.
Markdown rendering looks like a solved problem because the libraries that do the parsing are mature and well-tested. What's not solved by the library is everything around it — sanitization, caching, image handling, preview/production consistency — and that's the part of the pipeline that actually determines whether your content platform is reliable at scale.