A user opens Settings, taps Export my diary, and expects a ZIP of Markdown files to land in their downloads. That request is handled by GET /api/export — a Next.js API route that, in production, runs inside an AWS Lambda. There is no browser there. No window, no document, no editor.
But every diary and note is stored as a Lexical editor state: a JSON blob of nested nodes, not Markdown. Something has to turn that JSON into # Heading, - bullet, and `` ```code``` `` — server-side, on demand, for potentially hundreds of entries in one export.
The canonical way to do this with Lexical is @lexical/markdown's $convertToMarkdownString. It needs a live editor. This post is about why we didn't use it, and what a DOM-free tree walk buys you instead.
TL;DR
- A serialized Lexical state is just JSON —
root.childrenall the way down. You can walk it directly and never touch an editor or a DOM. - Storyie's
lexicalJsonToMarkdown(in@storyie/lexical-common) is a recursiveswitchonnode.type. It powers three server request handlers: the ZIP export API and two.mdroutes that serve public entries to LLM/plain-text crawlers. - We do use
@lexical/headlesselsewhere — for the reverse direction (Markdown → Lexical) and for Lexical → HTML. The HTML path even needs ahappy-dompolyfill with a hand-written table workaround. The export path deliberately avoids all of it. - The tree walk handles a platform-specific
ImageNodea headless node registry wouldn't know, and fills the GFM table gap@lexical/markdownleaves open. - Unknown node types degrade to their children/text instead of throwing — the opposite of a headless editor's
onError. - Testing is trivial and DOM-free: pure JSON in, Markdown string out.
Tree walk ( | Headless editor ( | |
|---|---|---|
Needs an editor instance | No | Yes |
Needs a DOM / polyfill | No | For the HTML path, yes ( |
Unknown node type | Degrades to children/text | Throws via |
Custom | Handled as a | Must be registered |
Tables | Inline, GFM | No default transformer |
The boundary: an export that runs where there is no DOM
Here is the top of the export route. Note dynamic = "force-dynamic" (per-request, no caching) and the three helpers pulled from the shared package:
// apps/web/app/api/export/route.ts (excerpt)
import { PassThrough } from "node:stream";
import { db, eq, publicProfiles } from "@storyie/database";
import {
extractImageUrlsFromContent,
lexicalJsonToMarkdown,
replaceImageUrls,
} from "@storyie/lexical-common";
import { ZipArchive } from "archiver";
import { NextResponse } from "next/server";
// ...
export const dynamic = "force-dynamic";This one converter has three production consumers, all server-side request handlers with no DOM:
apps/web/app/api/export/route.ts— the ZIP export (diaries and notes as Markdown, R2 images inlined).apps/web/app/(public)/diary/[slug].md/route.tsand.../note/[slug].md/route.ts— routes that serve a public entry as rawtext/markdownwith YAML frontmatter, "intended for LLM crawlers and other plain-text consumers".
None of them can spin up a browser. So the converter's very first design constraint is written into its file header:
// packages/lexical-common/src/markdown.ts (excerpt)
/**
* Lexical JSON → Markdown converter
*
* Server-safe implementation that directly parses Lexical JSON structure
* to produce Markdown output. Does not require DOM or headless editor,
* which allows us to handle ImageNode (platform-specific, not in lexicalCommonNodes).
*/Two claims in that comment do the real arguing: no headless editor and *handles ImageNode*. The next section is why both matter.
Why not $convertToMarkdownString? (we use headless editors — just not here)
It would be easy to read "we hand-rolled a converter" as reinventing a wheel. We didn't avoid @lexical/headless out of ignorance — the repo uses it in several places. The bot pipeline converts Markdown → Lexical with a real headless editor:
// packages/bots/src/lib/lexical-converter.ts (excerpt)
const editor = createHeadlessEditor({
nodes: [
HeadingNode,
QuoteNode,
ListNode,
ListItemNode,
CodeNode,
CodeHighlightNode,
LinkNode,
HashtagNode,
],
onError: (error) => {
throw error;
},
});That nodes array is the whole point. A headless editor must be told, up front, about every node type it will encounter — and if it meets a serialized node whose type isn't registered, onError fires and the conversion throws. For Markdown → Lexical that's fine: the input is Markdown, so the node set is fixed and known.
For the export direction the input is arbitrary stored JSON, and it contains an ImageNode that is platform-specific and deliberately not in the shared lexicalCommonNodes set (web and mobile render images differently; the node's serialization is shared but its renderer is not — see our cross-platform ImageNode post). To make $convertToMarkdownString handle it, we'd have to register a node whose rendering code we don't want in a Lambda, and write a Markdown transformer for it anyway.
And the Lexical → HTML path shows what "needs a browser" really costs. Storyie's server-side HTML viewer polyfills window, document, HTMLElement and friends from happy-dom before any Lexical node is imported — and monkey-patches Element.prototype.replaceWith to work around a happy-dom bug that otherwise strips every <table> from the output. That workaround is exactly the kind of yak-shave the tree walk never signs up for.
So: headless where the node set is fixed and an editor state is the goal; a tree walk where the input is open-ended and Markdown is the goal.
The converter is a tree walk over Lexical JSON
The entry point parses the JSON, grabs root.children, and recurses. A failed parse returns "" rather than throwing — an export should never 500 because one entry has malformed content:
// packages/lexical-common/src/markdown.ts (excerpt)
export function lexicalJsonToMarkdown(jsonString: string): string {
try {
const parsed = JSON.parse(jsonString) as LexicalNode;
const root = parsed.root;
if (!root?.children) return "";
return convertChildren(root.children, { depth: 0 }).trim();
} catch (error) {
console.error("Failed to convert Lexical JSON to Markdown:", error);
return "";
}
}Everything below that is a switch on node.type, dispatched per node:
// packages/lexical-common/src/markdown.ts (excerpt)
function convertNode(node: LexicalNode, ctx: ConvertContext): string {
switch (node.type) {
case "paragraph":
return convertParagraph(node, ctx);
case "heading":
return convertHeading(node, ctx);
case "list":
return convertList(node, ctx);
// ...
case "image":
return convertImage(node);
// ...
}
}No editor, no lifecycle, no node registry. The ctx.depth threaded through every call is the one piece of state that matters — it's how nested lists get their indentation.
Blocks, inline formatting, links, and code
The interesting bits are where Markdown's rules don't map one-to-one onto the tree.
Inline formatting is a bitmask. Lexical packs bold/italic/strikethrough/code into a single integer, so we test bits and wrap from the innermost mark outward to get valid nesting:
// packages/lexical-common/src/markdown.ts (excerpt)
const format = node.format ?? 0;
let result = text;
// Apply formatting (innermost first for proper nesting)
if (format & FORMAT_CODE) {
result = `\`${result}\``;
}
if (format & FORMAT_STRIKETHROUGH) {
result = `~~${result}~~`;
}
if (format & FORMAT_BOLD) {
result = `**${result}**`;
}
if (format & FORMAT_ITALIC) {
result = `*${result}*`;
}Ordered lists carry their own counter across nesting. A list item that itself contains a nested list is split into inline content plus the nested lists, the counter is incremented only for ordered items, and the nested list recurses with depth + 1:
// packages/lexical-common/src/markdown.ts (excerpt)
const indent = " ".repeat(ctx.depth);
const text = convertInlineChildrenFromArray(inlineContent, ctx);
if (isOrdered) {
parts.push(`${indent}${counter}. ${text}\n`);
counter++;
} else {
parts.push(`${indent}- ${text}\n`);
}
// Process nested lists with increased depth
for (const nested of nestedLists) {
parts.push(convertList(nested, { ...ctx, depth: ctx.depth + 1 }));
}Tables become GitHub-flavored Markdown inline — the header row, the | --- | separator, then data rows — precisely the transformer @lexical/markdown doesn't ship by default. Code blocks reassemble their code-highlight and linebreak children into a fenced block with the original language tag. All of it is string concatenation over parsed JSON.
Unknown nodes degrade instead of throwing
This is the design decision the headless path can't replicate. The switch has a default:
// packages/lexical-common/src/markdown.ts (excerpt)
default:
// Fallback: if it has children, convert them; otherwise return text
if (node.children) {
return convertChildren(node.children, ctx);
}
return node.text ?? "";A future custom node — some embed, a mention, a poll — that nobody taught the converter about still produces something: its visible text, or the text of its children. Compare the headless editor, whose onError throws the moment it deserializes a node type not in its nodes array. For a bulk export over years of user content, resilience is the correct default: one weird node should not blank out an entry, and it certainly should not fail the ZIP.
The image and hashtag cases are explicit precisely because they matter enough not to leave to the fallback — but the fallback is the safety net for everything we haven't enumerated.
Rewriting image URLs for an offline ZIP
An exported Markdown file that points at https://…r2.dev/… is useless offline, so the export rewrites every remote image URL to a local relative path and hands back a URL → filename map the route uses to download each file into the ZIP:
// packages/lexical-common/src/markdown.ts (excerpt)
export function replaceImageUrls(
markdown: string,
imageUrls: string[],
options?: {
imagePrefix?: string;
sharedUsedNames?: Set<string>;
sharedImageMap?: Map<string, string>;
}
): { markdown: string; imageMap: Map<string, string> } {The shared* options are what make a multi-document export coherent: pass one sharedUsedNames set and one sharedImageMap across every entry, and two files named photo.jpg in different diaries deterministically become photo.jpg and photo-1.jpg — no collisions, and the same URL always maps to the same local file.
Testing is trivial because the function is pure
The payoff of "no editor, no DOM" shows up loudest in the test file. Because the converter is string -> string, the suite in packages/lexical-common/__tests__/markdown.test.ts just builds small JSON fixtures and asserts on substrings — no JSDOM, no happy-dom, no editor bootstrap:
// packages/lexical-common/__tests__/markdown.test.ts (excerpt)
it("falls back to children or text for unknown node types", () => {
const json = makeLexicalRootState([
{ type: "custom-node", children: [paragraphNode([textNode("from children")])], version: 1 },
{ type: "custom-text", text: "plain fallback", version: 1 },
]);
const markdown = lexicalJsonToMarkdown(json);
expect(markdown).toContain("from children");
expect(markdown).toContain("plain fallback");
});The fixture builders (makeLexicalRootState, paragraphNode, textNode) are plain object factories. The suite walks the whole surface this way: headings clamp h99 down to ######, ordered counters increment across nesting ( 2. Nested one, 3. Nested two), code fences keep their language, tables emit | --- | --- |, and replaceImageUrls deduplicates photo.jpg into photo-1.jpg. The contrast is stark against the bot converter's tests, which spin up "the real @lexical/headless editor" just to exercise the reverse path.
Takeaways
- A serialized editor state is data. If your output is Markdown (or plain text), walking the JSON is often simpler and more robust than reconstructing an editor to serialize out of.
- Reach for a headless editor when the node set is fixed and you actually need an editor state — Markdown → Lexical, or Lexical → HTML. Reach for a tree walk when the input is open-ended and you control the output format.
- Make the unknown-node case degrade, not throw. Exports run over content you didn't write.
- Purity is a testing strategy:
string -> stringmeans no DOM in the test runner and fixtures that are just objects.
Related reading: How we built a cross-platform ImageNode for Lexical (the platform-specific node this converter has to serialize), Cross-platform Lexical with use dom, and Local-First Autosave Without Losing the Last Keystroke (how the JSON this export reads gets written in the first place).