Writing a Markdown serializer for your block, step by step

Step seven of the block checklist is the one everyone skips. Miss it and your block renders fine in HTML and vanishes from .md without a warning.

fac4fcad6c8606a161dc6fb528f1862053be2bc1-2560x1440.jpg

Adding a page-builder block to Turbo Start is an eight-step job. Seven of those steps fail loudly if you skip them: miss the schema export and the block never appears in the Studio, miss the GROQ projection and the data arrives empty, miss the render case and React throws. Step seven is different. Skip it and everything looks fine.

What actually breaks

Every page on this site is also served as Markdown. Append .md to any URL, or send Accept: text/markdown, and you get the same content as clean Markdown. That path runs through a separate serializer, and it has no idea your new block exists.

The dispatcher returns an empty string for any type it does not recognise, and the caller then filters empty strings out. Your block does not error, does not warn, and does not appear. The HTML page is complete; the Markdown is quietly shorter.

The dispatcher is deliberately thin

That is the whole of internal/page-builder-to-markdown.ts. It holds no formatting logic on purpose — each serializer lives in its own block directory next to the schema, the projection and the component, so the four move together.

Write the serializer

Create markdown.ts in your block folder. The CTA block is a good template — it is four helper calls and nothing else.

Reach for the shared helpers before writing your own. joinSections drops empty parts and joins the rest with blank lines, so a half-filled block still produces valid Markdown. headingToMarkdown takes a level, which keeps the document hierarchy sane. portableTextToMarkdown handles rich text, marks and links. And the helpers escape Markdown characters for you, so an eyebrow reading #1 _Pick_ does not turn into a heading.

Wire it up

Import your function in the dispatcher and add one case matching the block's _type exactly. A typo here fails the same silent way as skipping the step entirely, because the switch falls through to the default.

Test that nothing leaks

Assert the empty case too — an empty block should return an empty string, not a stray heading. And assert no JSX leaks: because the serializer walks structured data rather than React, a component name should never be able to reach the output. The test is what proves that stays true.

The rules

  • Co-locate the serializer with the block. A block folder that is missing one is the bug.
  • Use the shared helpers so escaping and spacing stay consistent across blocks.
  • Serialise content, not chrome. Decorative wrappers and layout have no Markdown equivalent — leave them out.
  • Compare the two outputs after adding a block: load the page, then load it with .md appended. Anything present in one and missing from the other is a gap.

It is ten minutes of work at the end of a block, and it is the difference between a page an agent can read and one it sees half of.