Why I Replaced the Python Backend with TypeScript in ncmds
A few weeks ago I published a post about building ncmds — a zero-configuration, Markdown-driven documentation site builder I was running on Flask + Python. The post got some traction, a handful of stars on GitHub, and a few contributors poking around the code. That was great.
Then I quietly rewrote the entire backend in TypeScript.
This post is the honest story of why.
Where ncmds Started
The first version of ncmds was pure Python. Flask handled HTTP routing, python-markdown did the rendering, Jinja2 templated the pages, and PyYAML parsed frontmatter and configuration. It worked. It was boring in the best possible way — which is exactly what I praised in the original post.
So why change anything?
The Trigger: Vercel Deployment Pain
The breaking point was cloud deployment. I wanted ncmds to be trivially deployable on Vercel with a single click. Vercel does support Python via serverless functions, but the experience is a second-class citizen compared to Node.js. Cold start times were noticeably higher, the Python runtime has a smaller set of supported library versions, and every time I updated a dependency I had to fight the requirements.txt freeze dance.
Node.js is Vercel's native language. The platform is built around it. Deploying a TypeScript Express app to Vercel is essentially a no-op — you push, it builds, it runs. That gap in deployment ergonomics was reason enough to at least evaluate the switch.
The Case for TypeScript (Not Just JavaScript)
Once I decided to move away from Flask, the follow-up question was: plain JavaScript or TypeScript?
I chose TypeScript for a specific reason that goes beyond the usual "type safety is good" argument: ncmds already had a frontend written in TypeScript. The client-side search index, the AI chat module, and the model-switcher dropdown were all .ts files compiled with tsc. Running two separate compilers — tsc for the frontend and the Python interpreter for the backend — felt like unnecessary cognitive overhead.
With TypeScript on both sides, I now have one tsconfig.json at the root that covers the server, and a tsconfig.frontend.json for the browser-targeted code. One toolchain. One npm run build. Everything in sync.
// package.json (abridged)
{
"scripts": {
"dev": "tsx src/server.ts",
"build": "npm run build:server && npm run build:frontend",
"start": "node dist/src/server.js"
}
}
Running tsx during development means I get hot reloads without a separate watcher process. It's as ergonomic as flask run --debug, but without crossing a language boundary.
The Migration Was Surprisingly Smooth
Here is the part I did not expect: the Jinja2-to-Nunjucks migration was almost mechanical.
Nunjucks is, for all practical purposes, Jinja2 ported to JavaScript. The syntax is nearly identical:
{# Jinja2 #}
{% for page in pages %}
<li>{{ page.title }}</li>
{% endfor %}
{# Nunjucks #}
{% for page in pages %}
<li>{{ page.title }}</li>
{% endfor %}
Every template I had written in Jinja2 worked in Nunjucks with zero or minimal edits. That was a pleasant surprise.
The Markdown pipeline was a similar story. markdown-it (with markdown-it-anchor for heading IDs) is a well-maintained, plugin-based renderer that maps cleanly to what python-markdown was doing. The gray-matter package replaced PyYAML for frontmatter parsing — a one-line swap for a much more ergonomic API.
The only genuinely tricky piece was the YAML configuration loader. PyYAML's safe_load is extremely forgiving about undefined fields. js-yaml is stricter, and I had to add explicit null-coalescing throughout the config-reading code. A minor annoyance, not a blocker.
What TypeScript Gives You That Python Doesn't (in This Context)
I want to be precise here because "TypeScript vs Python" is a useless framing for most problems. In this specific context — a Node.js web server that also compiles frontend code — TypeScript has a few concrete advantages:
Shared types across server and client. I defined a PageMeta interface once. The server uses it when it parses frontmatter, the frontend uses it when it renders the search index. In the Python version these were implicitly duplicated dictionaries with no enforcement.
Catch shape mismatches at compile time. The config YAML has a well-defined structure. With a TypeScript interface for it, the compiler tells me immediately if I access config.hero.enabled but the actual loaded object has no hero key. Previously that was a runtime KeyError in production.
Express 5 async error propagation. Express 5 (which ncmds uses) natively propagates errors thrown from async route handlers without needing wrapper boilerplate. Combined with TypeScript's Promise-aware inference, writing safe async routes became much cleaner than Flask's mix of sync and async view functions.
What I Gave Up
Python is a better scripting language than Node.js for certain tasks, and ncmds did lean on that. The PDF export module used WeasyPrint, which is a Python library with no obvious JavaScript equivalent of the same quality. After the rewrite, PDF export became an optional feature that requires a separate Python process or an external service — it's no longer bundled by default.
That is a real regression. I acknowledge it. For the majority of users who want a fast, local, cloud-deployable documentation site it doesn't matter. For the users who relied on the PDF pipeline, I'm still working on a replacement.
I also gave up the simplicity of pip install -r requirements.txt for users who are more familiar with Python than Node.js. The npm ecosystem is powerful but noisier — the package-lock.json is enormous compared to a requirements.txt.
The Current Stack
| Layer | Before | After |
|---|---|---|
| HTTP server | Flask | Express 5 |
| Language | Python 3 | TypeScript |
| Templating | Jinja2 | Nunjucks |
| Markdown | python-markdown | markdown-it |
| Frontmatter | PyYAML | gray-matter |
| Config | PyYAML | js-yaml |
| Dev runner | flask run | tsx |
| Build | (none) | tsc |
The surface area is comparable. The deployment story is dramatically simpler. And the whole project — server and client — speaks one language.
Was It Worth It?
Yes, for my use case. ncmds is now at v5.0.0, and the Vercel deployment works exactly as I wanted: one click, sub-second cold starts, no runtime version headaches. The codebase is easier to navigate because a contributor no longer needs to switch mental models between Python and TypeScript as they move through the repository.
The lesson I take from this is not "TypeScript is better than Python." It's that language homogeneity in a full-stack project has compounding benefits — shared types, shared tooling, shared mental model — and those benefits tend to outweigh the switching cost once your project reaches a certain size.
If ncmds were purely a backend tool with no client-side logic, Flask would still be the right answer. But it isn't, and it wasn't.
Try the New Version
git clone https://github.com/edujbarrios/ncmds.git
cd ncmds
npm install
npm run dev
# → open http://localhost:5000
The quick-start experience is identical to the Python version. The internals are completely different. That, to me, is the best possible outcome of a backend migration.
You can read the original post about why I built ncmds in the first place here.
