SEO and Server Rendering

By Steph24
Scroll to open

Put .md on the end of this page's address and the server hands back the same content as plain markdown. Nothing was exported and nothing is generated ahead of time. Try it on any public slate.

Rendered Before It Is Requested

A slate page is fetched from the backend and rendered to HTML in Express before React ever runs in a browser. A crawler gets the finished article; a person gets the same HTML and then hydrates on top of it. It is a synchronous string render, not streaming, and no page HTML is cached at all. Assets get a year, the sitemap gets an hour, pages get nothing. For a catalogue where any slate can be edited at any moment that is the safe default, and it is a real cost paid deliberately.

The Markdown Is Not a Serializer

This is the part that surprises people who look. You would expect the .md route to walk the chips and write markdown for each one. It does not. It renders the full React page to HTML exactly as a browser would receive it, selects the chip grid out of that HTML, and converts the result with Turndown. The markdown is downstream of the rendering, not parallel to it.

typescript
export const htmlToMarkdown = (html: string): string => {
  const $ = cheerio.load(html);
  const mainContent = $('[data-liquid-grid]').html();
  return td.turndown(mainContent ?? html);
};

Why That Way Round

A parallel serializer is a second implementation of every chip, and the second one always drifts. Going through the rendered DOM means a new chip is readable by a language model the day it ships, with no extra work and no possibility of the two versions disagreeing. The cost is that anything the chip expresses visually rather than textually disappears. Scripts, buttons, inputs and every SVG are stripped, so the Map, Shader and Stats chips leave nothing behind. Images come out with empty alt text, because the component passes an empty alt.

Six Chips Get Hand-Written Rules

Where the generic conversion produced something poor, a specific rule was added, keyed off a data attribute the component already emits. Counter becomes a bold number and a label. Link becomes a proper markdown link. Quote and Note become blockquotes. Task becomes a checkbox with its deadline. Duration becomes a dated range. Everything else falls through to whatever its markup happens to convert to.

One of the Six Has Never Fired

The Note rule matches an aside element carrying a chip attribute. The Note chip renders two plain divs with neither. The rule is syntactically fine, sits in the file next to five that work, and has never once matched a node. Note chips have always gone through the generic path. Nothing about the output looks broken, which is exactly why it survived.

The indexing work, in order16.03.2026 – 08.07.2026
16.03.2026Slates start appearing in a dynamically generated sitemap.
16.03.2026
20.03.2026The .md extension ships for LLM consumption and clean exports.
20.03.2026
29.03.2026Canonical link tags added to every server-rendered page.
29.03.2026
03.04.2026Markdown extended to sub-pages.
03.04.2026
25.04.2026llms.txt published for crawler discovery.
25.04.2026
29.04.2026robots.txt starts naming AI crawlers explicitly.
29.04.2026
08.07.2026A fallback that writes crawlable HTML straight from chip data.
08.07.2026

The Sub-Page Claim Is Half True

The April entry says markdown works on sub-pages. On the numeric form of a URL it does. On the canonical form it does not: the pattern that detects the extension has no group for a path segment after the slug, so the address is read as a page named with a .md on the end and served as HTML. No error, no warning, just the wrong content type. It has been that way since the entry was written.

Sitemap row cap

50000

Sitemap cache

1 hour

Meta description cut at

160 characters

Chips with a hand-written markdown rule

6

Page HTML cache

none

The Fallback Nobody Sees

If the React render throws, the page would ship an empty body and a crawler would index nothing. So there is a second path that builds plain HTML directly from the chip data: a heading, the author, then each chip in row order as paragraphs, blockquotes, lists and preformatted blocks. It is uglier than the real page and it is only ever served when the real one failed. It handles about sixteen chip types and returns nothing for the rest.

Where the Head Contradicts Itself

Worth writing down rather than quietly fixing before publishing this. The canonical tag on a slate points at the /@user/slug form. The structured data in the same document still points at the old /u/ form, which the same server 301s away. And one slate type maps to the literal string TBD instead of a real schema.org type, which has been shipping in production JSON-LD since the mapping was written. Both are small. Both are the kind of thing you only find by reading your own output.

What This Is Worth to a Builder

A project page that renders on the server, sits in a sitemap, and answers in markdown when a model asks for it is a project page that is still legible in five years to readers you did not anticipate. None of that required writing a site. It required putting the content somewhere that already does it.