Toolchain posts usually list what somebody picked. That is the half you cannot use. The half you need is what the alternatives still do better — that is what tells you whether the choice fits your project, and what it costs if it stops fitting.
So: what the kit is built with, what each decision cost, and the part we did not plan for.
Bun, and what pnpm still does better
Bun is the package manager, the script runner and the TypeScript runtime. Installs land roughly 4–5× faster than pnpm on cold CI and 3× warm, which compounds over a loop you run thousands of times. bun run executes TypeScript directly — no tsx, no ts-node, no separate dev runtime. bun.lock is text, so it diffs.
The honest column:
| pnpm still does this better | why it did not change the call |
|---|---|
| Overwhelming ecosystem adoption | CI scripts here are short; a one-time learning cost |
pnpm patch for in-tree dependency patches | If a dependency needs patching it should be forked |
| Strict phantom-dependency prevention | Bun is looser; Biome’s import rules cover most of it |
Two-sided on purpose: if your priorities are install speed, single-tool DX and native TypeScript, take Bun. If they are ecosystem maximalism and ten years of battle-testing, take pnpm. Either way the exit is cheap — switching back is about a day, because the lockfile converter handles resolution.
That last part is what makes a decision safe to take. A choice you can reverse deserves far less agonizing than one you cannot.
TypeScript 7, from day one
The kit is on TypeScript 7 — ^7.0.2 — and has been since the first commit.
TS 7 is the Go rewrite that shipped stable in January 2026, and type-checking is roughly an order of magnitude faster than the JavaScript implementation. In a monorepo that is not a nicety: typecheck runs across every package on every change, and the gap between tens of seconds and a couple is the gap between running it constantly and running it when you remember.
Taking it on day one was cheap: compatibility with 5.x is around 99%, so there was no migration to do — only one to avoid later.
The honest caveat is the emit pipeline, still completing through mid-2026: no decorator support, downlevel emit incomplete. Neither costs us anything, because the kit uses no decorators and targets ES2022 and above. If your codebase is built on decorators, you cannot make this call yet.
Turborepo, tsdown, Vitest
Turborepo for caching. Re-running a build skips unchanged packages, and remote caching shares that across CI and machines. turbo.json is short enough to read in a sitting and has needed almost no attention since.
Nx is genuinely more capable, but our experience was not the one the feature list predicts: noisy output, a configuration that took real effort to get right and more to keep right, and steady prompting toward its hosted caching service. That last one wore thin. A build tool interrupting your build to market at you is a small cost once and an irritating one at the hundredth. Turborepo has remote caching too and does not spend your attention advertising it.
tsdown for building libraries. It wraps Rolldown — the Rust rewrite of Rollup — with the defaults library authors want: dts generation, multiple output formats, exports validation, bin detection from a shebang. Vite is migrating to Rolldown too, so the bet is that it sharpens rather than bit-rots.
Vitest for tests, with one Cloudflare-specific reason outweighing the rest: @cloudflare/vitest-pool-workers runs your tests in the real Workers runtime. Testing against a mock of Workers proves the mock works.
Biome, and the caveat that came true
Biome is the easiest tool in this list to live with and by a distance the fastest. It replaces ESLint and Prettier entirely — linter, formatter and import sorter in one Rust binary that finishes a medium monorepo in well under a second, against the five to thirty seconds the same work took before.
Speed is the headline and it is not the reason. The reason is one tool and one config file. No @typescript-eslint, no eslint-plugin-import, no prettier-eslint arguing over who owns formatting, and no afternoon lost to a plugin resolution error.
Our own notes recorded the risk at the time:
If you need rules that Biome doesn’t have (e.g., advanced React rules, custom plugin lints), ESLint + Prettier is the safe fallback.
We needed custom plugin lints. What we did not anticipate is that Biome would grow GritQL plugins, so the caveat resolved in the other direction — and writing three of them taught us more about the codebase than the tools did.
Rules a type system cannot express
Each encodes an architectural decision true across the repo and invisible to tsc.
no-console.grit. Console is not the logger. A console line has no level, no name and no request id, and Workers Logs cannot filter it.
Biome already ships suspicious/noConsole, and it matches exactly what ours matches. We do not use it, for one reason:
its message is fixed and names no replacement — it states the prohibition and stops. A rule that only prohibits gets
biome-ignored by the next person who needs a line of output, since the rule never told them where the line was supposed to go.
Ours names the replacement in the diagnostic — c.var.log inside a request, createWorkerLogger() outside one. That is the entire difference between the two rules, and the difference between a gate people pass through and one they climb over.
no-process-io.grit. The same hole through process.stdout. It is a separate plugin because its scope is inverted: the CLI is excluded. Writing to stdout is what a CLI is for, and --json on stdout is a contract every command owes an agent driving it.
The reason for adding it is the best argument in the three files:
There are no violations to fix outside the CLI today. That is the point of adding it now: the gate is free while the count is zero, and it stops the first one arriving quietly in a Workflow where it would fail at runtime rather than in review.
no-raw-request-input.grit. Flags c.req.query(), c.req.json() and c.req.param() in route code, leaving c.req.valid() as the only route to request input — and that does not exist until a validator declared it. Unvalidated input becomes unreadable rather than discouraged.
Two things writing them taught us
Match the member expression, not the call. The first console rule matched console.$method($...) — the call form. So items.forEach(console.log) walked straight through, as did assigning the method to a variable. Matching console.$method subsumes both, and still reports once on a normal call.
That bug is the lint-rule form of a walk that stops early: not reporting clean code, but reporting that it had not looked.
Aim at accident, not evasion. None of the three chase aliasing — const c = console; c.log(x) passes. That is deliberate, and the reasoning is worth adopting generally:
someone routing around the gate has
biome-ignoreavailable, which no matcher can block, so the alias case is evasion rather than accident and the gate is aimed at accident.
A linter cannot stop a determined person, and designing as though it can costs complexity for no protection. What it can do is stop you arriving at the wrong pattern by not knowing better — and, if the diagnostic is written properly, tell you where the right one lives.