Frontend build chaos
Table of contents
The straw that broke my back §
I’ve come to realize the sin of Tailwind CSS:
<form class="w-132 p-2 flex items-center bg-bg-100 rounded-md focus-within:outline-2 focus-within:bg-transparent outline-focus"> <span class="icon-[mingcute--search-line] mx-2 hidden xs:inline"></span> <input id="search" type="search" autocomplete="off" value="1 Chronicles" class="grow outline-none w-0" aria-label="Search"></form>I’m ashamed to say I wrote this. So I began to migrate off it. Tailwind has
some JS that transforms p-2 into:
.p-2 { padding: calc(var(--spacing) * 2);}Neato. I want to keep the design system of a configurable --spacing so I can
dynamically try out different spacing values. But I don’t want to have to
remember to write calc(var(--spacing) * 2). Enter
CSS @functions:
@function --padding(--amount) { result: calc(var(--spacing) * var(--amount));}Now I can write:
.my-element { padding: --padding(2);}I’m a happy customer.

All styling concerns are kept in my CSS file just like Tim Berners Lee envisioned all those years ago. But, sadly browser support is lacking. The standard is still a draft and only Chrome has support. No problem, I’ll just find a polyfill.
There’s two popular CSS transformers: PostCSS and LightningCSS. Surely one of them has a transformer for a nearly 2 year old proposal, right?
Well, I’ll just write my own. My bundler Vite uses PostCSS by default, so I’ll fork a nice branch and iterate.
OH, but I forgot, my linter is also sad:
/* https://github.com/biomejs/biome/issues/8184 *//* biome-ignore lint/suspicious/noUnknownAtRules: they're standard */@function --padding(--amount) { /* biome-ignore lint/correctness/noUnknownProperty: they're standard */ result: calc(var(--spacing) * var(--amount));}And if I put url statement in there my bundler won’t know how to resolve
them. I guess I could use SASS and a loader…
WHY DO I NEED BUILD TOOLS? §
Working with them sucks. Development builds should be production ones.
Developing websites should be as simple as file://myproject.html. Web
components should be the best way to write interactive components. Not React,
Vue, Angular, Solid, Lit, Inferno, KO, or the newest
fastest framework.
JS should be the best way to write interactive code, not Typescript or WASM. New features should be backwards compatible, not require Babel, Closure, or swc.
CSS should be the best way to write styles in a backwards-compatible manner, not SASS, LESS, PostCSS, or LightningCSS.
And I shouldn’t have to bundle ANYTHING, there should be a standard, performant module system with minimal overhead. Not:
I’m sorry, but a pre-build step is not “LIGHTNING FAST” like all of them advertise.
Linting, haha §
Oh, there’s a whole separate linter ecosystem. ESLint, Biome, and quick-lint-js all have separate parsers, rulesets, and formatters. And there’s the Typescript compiler which may or may not be a “linter” to your workflow.
chaos §
It’s pure chaos. Out of the box my TSX is being parsed by:
ts-serverfor autocompletion- Linter for diagnostics
- Bundler for imports
- A JS runtime for configuration like
vite.config.ts - Babel for compatibility and language features
- Occasionally
tscfor typechecking
My CSS is being parsed by:
css-lsportailwindcssor whatever LSP I’m using- Linter for diagnostics
- Bundler for imports
- PostCSS for compatibility and language features
My HTML is being parsed by:
- LSP
- Linter for diagnostics
- Bundler for imports
Whenever a new feature is added to any of these language, 3-6 tools have to catch up for me to have a good experience using them.
RomeJS’s failed promise §
Rome promised to unify developer tooling. There has been a recent (~5 year) push towards Rust build tooling, mostly spurred on by esbuild’s simplicity and success. Then they ran out of money and just shipped a linter.
New tools fracture the existing Babel, Rollup, and PostCSS ecosystem. If you need ONE plugin that’s only offered by Babel, Rollup, or PostCSS, well, you have to shell out to slow old JS and use it. It defeats the purpose of your new “LIGHTNING FAST” build tool.
And of course each comes with its own AST, utils, config file, and plugin interface.
Paths to unification §
I want a single unified LSP and build tool with ONE config file that specifies targets environments, entrypoints, and configuration for EVERYTHING.
1. The old JS way is better §
JS can always be extended with JS. Existing tools are mostly there. Refactor what already exists to be unified.
Since I don’t want a forever project (and to maintain it), I choose this option.
2. Rewrite in Rust | Zig | C++ | Go §
JS parsers are sloooow and use lots of memory. Typescript’s compiler also sucks, so replace it or do away with it. Typescript is being rewritten in Go and oxc has core tools for Rust. Start from scratch and pick some reasonable backwards compatibility cutoff.