The same parser, four times
Over the last year I kept running into the same shape of problem:
- A docs site generator that wanted Markdown chapters turned into navigation JSON.
- A RAG ingestion script where each Markdown file needed to become a list of text chunks plus its frontmatter metadata.
- An n8n flow that took Markdown emails and extracted only the tasklists.
- A static-site backend that accepted user Markdown and needed to validate structure before persisting.
Each one is small on its own. But every time I reached for a different library — remark here, gray-matter there, marked once, a hand-rolled regex once too many — and every time one of them broke on the same edge cases:
- Nested GFM tasklists where the
checkedstate was silently lost - YAML frontmatter that included quoted booleans (parsed as strings, not booleans)
- Tables whose headers contained spaces (regex parsers treated them as one key)
- Code blocks containing Markdown — re-parsed as Markdown instead of fenced code
So I built one endpoint that does it once, properly.
What it returns
POST /v1/parse takes a Markdown body (text/markdown) or a JSON envelope
(application/json) and returns one stable JSON shape:
{
"success": true,
"data": {
"title": "Project Alpha",
"frontmatter": { "title": "Project Alpha", "status": "shipping" },
"headings": [ { "level": 1, "text": "Project Alpha", "id": "project-alpha" } ],
"sections": [ { "heading": { ... }, "children": [ ... ], "content": [ ... ] } ],
"lists": [ { "ordered": false, "items": ["ship MVP", "write README"] } ],
"tasklists": [ { "items": [ { "text": "ship MVP", "checked": true } ] } ],
"tables": [ { "headers": ["Module","Status"], "rows": [{"Module":"API","Status":"Done"}] } ],
"codeBlocks":[ { "lang": "js", "value": "..." } ],
"links": [ { "text": "...", "url": "https://..." } ],
"paragraphs":["..."],
"ast": null
}
}
The sections tree is the part I care most about. It's not just a flat list of
headings — it's a hierarchy built from heading depth, with each section carrying
its own content array of the lists/paragraphs/code-blocks that live under it.
For RAG ingestion, that's the chunk boundary you want without an extra pass.
The ast field is optional and null by default. If you turn it on with
{ "includeAst": true } you get the full mdast tree the parser built — useful
when you need to write a custom visitor and skip my abstractions entirely.
A 30-second path from curl to JSON
curl -X POST https://md2json.p.rapidapi.com/v1/parse \
-H "content-type: text/markdown" \
-H "X-RapidAPI-Key: YOUR_KEY" \
--data '---
title: "Sample"
status: shipping
---
# Project Alpha
- [x] Ship MVP
- [ ] Write docs
| Module | Status |
|--------|--------|
| API | Done |
| UI | To-do |
'
Response highlights:
{
"data": {
"title": "Sample",
"frontmatter": { "title": "Sample", "status": "shipping" },
"tasklists": [{ "items": [
{ "text": "Ship MVP", "checked": true },
{ "text": "Write docs", "checked": false }
]}],
"tables": [{ "headers": ["Module","Status"],
"rows": [ { "Module": "API", "Status": "Done" },
{ "Module": "UI", "Status": "To-do" } ]}]
}
}
Why an API, not a library?
Three reasons, in order of importance:
- Same parser in every stack. A Python ingestion script, a Node SSG, an n8n flow and a browser extension can all hit one URL with one shape. I don't have to keep four copies of the same frontmatter extraction synchronized.
- No state to manage. It's a stateless Cloudflare Workers function. There's no DB to back up, no auth server to patch, no proxy to babysit. If traffic drops to zero tonight, the bill is still $0.
- Better isolation for bad input. When the parser hits a malformed frontmatter block we return a 422 with a stable error code; the caller decides whether to skip, retry or fail. No exceptions leaking across language boundaries.
The differentiator story
RapidAPI already has a markdown-to-json API by RMHighlander. It's been up
for years, has exactly 1 subscriber and no rating. I dug into it before
shipping and the gap is real — it doesn't extract frontmatter, doesn't
advertise GFM, doesn't build a heading tree. The adjacent Anything-to-Markdown
API goes the other way (anything → Markdown) and charges $10-$99/mo, but it
isn't the tool when JSON is what you need.
So there's an open slot on the structured-JSON-output side for the
Markdown-first developer. That's the one I'm trying to fill:
-
Frontmatter first-class — YAML or TOML
---block, parsed into a flat object, ready to join with the rest of the document. -
GFM by default — tables, tasklists with
checked, strikethrough and autolinks without an opt-in. - Hierarchy-aware sections — not a flat list; a tree built from heading depth, ready for RAG chunk boundaries or nav UIs.
-
MIT upstream — built on the
remarkfamily. No proprietary parser to be locked into. - Edge-native — runs on Cloudflare Workers, ~16ms cold start, p95 under 150ms on the dev URL so far.
Stack
| Layer | Choice | Reason |
|---|---|---|
| Runtime | Cloudflare Workers | Stateless, free tier covers 100k req/day, no DB needed |
| Parser core |
unified + remark-parse
|
De-facto Markdown AST standard |
| GFM | remark-gfm |
Tables, tasklists, strikethrough, autolinks |
| Frontmatter |
remark-frontmatter + gray-matter
|
Both YAML and TOML support |
| Lint | Biome 2.5 | One tool, fast, no .eslintrc chaos |
| Tests | Vitest 2.1 + unstable_dev
|
Real Worker runtime in the test process |
| API contract | OpenAPI 3.0 | Published at /openapi.yaml on the Worker itself |
Bundle size is 562 KiB raw, 113 KiB gzipped — happy for a Workers function.
31 integration tests; the router, parser, auth-fail-closed and rate-limit
logic all run against real Worker infrastructure, not a mock.
Free tier shape, for people who want to actually try it
- BASIC — $0: 100 requests/day per key, hard-capped (no overage). Enough to drive a real hobby SSG or one-off ingestion script.
- PRO — $9/mo: 10k requests/day. Side-project territory.
- ULTRA — $29/mo: 100k requests/day. Hitting the global CF free-tier ceiling; if you need this you should probably talk to me about a paid Workers tier.
All tiers return the same schema. There are no gated output fields in the
current release; if I add serious ast-based Pro features they'll be opt-in
via the JSON envelope, not by stripping free-tier output.
Where to go next
- Live listing (free key, ~30 seconds): rapidapi.com/bodormiki09/api/md2json
- Source + OpenAPI spec + deploy notes: github.com/MCgatya09/md2json
-
Health check (no auth required):
https://md2json-prod.md2json-dev.workers.dev/healthz
I'm in the middle of a 14-day validation cycle. If you hit a Markdown edge
case the parser gets wrong, file a GitHub issue — I'll fix it before the
cycle closes and the issue tracker stays public afterwards. If the schema
is missing a field you need, same thing. The differentiator only holds
while the implementation actually feels good to use.
Top comments (0)