I’ve been a Node.js developer for years. I’ve written hundreds of npm init, wrestled with tsconfig.json, configured Webpack, Rollup, esbuild — you name it. And every single time I wanted to create a small, reusable utility module, the setup ceremony felt like planning a wedding when all I wanted was a coffee.
Then I switched to Bun. And honestly? I’m not going back.
Here’s a real module I built — image-gen, a reusable AI image generation CLI and library powered by Google Gemini. The whole thing is under 100 lines of actual logic.
The Module: image-gen
Here’s what it does — you give it a text prompt, it generates an image using Gemini’s image model, saves it to disk, and returns the result. It works both as a CLI tool and a programmatic library.
# As a CLI
bunx image-gen "Abstract data flow visualization" --type blog --name header
# As a library
import { generateImage } from "image-gen";
const result = await generateImage({ prompt: "React hooks diagram" });
That’s it. One module, two interfaces, zero build step.
Why Bun Makes This Stupidly Easy
1. Native TypeScript — No Build Step, No Config
With Node, creating a reusable TypeScript module means setting up tsconfig.json, a build tool (tsc, esbuild, tsup, unbuild — pick your poison), output directories, source maps, declaration files… the list goes on.
With Bun? You just write .ts files and ship them.
{
"name": "image-gen",
"module": "src/index.ts",
"types": "src/index.ts",
"bin": {
"image-gen": "bin/cli.ts"
}
}
Notice anything? The module and bin fields point directly at .ts files. No dist/ folder. No compilation. Bun runs TypeScript natively, so your source IS your distribution. This alone eliminates half the files in a typical Node module.
2. bun link — Local Modules Without the Pain
Node’s approach to local module development is… rough. You’ve got npm link (which is famously unreliable), file: dependencies (which copy instead of symlink), or workspace setups with Lerna/Turborepo/Nx for what should be a simple operation.
Bun gives you bun link:
# In the module directory
cd ~/src/tasks/image-gen && bun link
# In any project that wants to use it
cd ~/src/personal/profile && bun link image-gen
Done. It just works. The symlink is instant, changes are reflected immediately, and there’s no phantom dependency hell. I use this across multiple projects — my portfolio, a Christian AI project, a tech blog — all sharing the same image-gen module with different style presets.
3. First-Class Bun.write() and Native APIs
Look at how the module writes the generated image to disk:
await Bun.write(filepath, imageBuffer);
One line. Compare this to Node where you’d need:
import { writeFile } from "node:fs/promises";
await writeFile(filepath, imageBuffer);
Okay, that’s not dramatically different in this case — but Bun.write() is optimized under the hood. It uses the fastest system calls available (like sendfile on Linux), and it handles Buffer, Blob, Response, string, and even Bun.file() references natively. The API is designed for the common case, not the lowest common denominator.
4. The CLI Just Works
The bin field in package.json points to a TypeScript file with a shebang:
#!/usr/bin/env bun
import { generateImage } from "../src";
// Parse args, call generateImage, done.
With Node, a CLI binary in TypeScript requires either:
- A build step to compile to JS first
- A wrapper script that invokes
ts-nodeortsx - Some
esbuildbundle step in your prepublish hook
With Bun, the shebang #!/usr/bin/env bun is all you need. The file runs as-is.
Okay but how fast is it actually?
“Bun is fast” is easy to say. Here are the numbers.
| Operation | Node | Bun | Speedup |
|---|---|---|---|
hello world script | ~70ms | ~7ms | 10x |
Import-heavy CLI (like image-gen) | ~300ms | ~30ms | 10x |
install (cold, no cache) | ~15s | ~3s | 5x |
install (cached) | ~5s | ~0.5s | 10x |
That 10x startup difference matters more than you think. Those milliseconds compound into minutes across a day.
And bun install? It uses a global module cache with hardlinks, so installing the same package in a new project is basically free after the first time. Node’s npm copies everything. Every. Single. Time.
# Timing a fresh install of this module's dependencies
# Node (npm)
time npm install # 4.2s
# Bun
time bun install # 0.3s
For CPU-bound work the difference is smaller, but for tooling and scripting? Bun wins handily.
The preset trick
One design pattern I love in this module is how it handles style customization through presets and system prompts:
// Built-in presets for common use cases
import { generateImage } from "image-gen";
await generateImage({ prompt: "...", preset: "tech-blog" });
// Or pass a custom system prompt for full control
import { systemPrompt } from "./image-prompt";
await generateImage({ prompt: "...", systemPrompt });
Each project defines its own visual style in a local image-prompt.ts file, while the core generation logic stays in the shared module. The module doesn’t need to know about every project’s aesthetic preferences — it just provides the hooks.
This is the kind of clean separation that Bun’s module linking makes trivial. Change the prompt in one project, the module doesn’t care. Update the Gemini API logic in the module, all projects benefit immediately.
The folder structure
Here’s what I’ve settled on:
image-gen/
package.json # module + types point to .ts files directly
bin/cli.ts # CLI entry point with #!/usr/bin/env bun
src/
index.ts # Public API exports
generate.ts # Core logic
types.ts # TypeScript interfaces
presets/ # Built-in configurations
tsconfig.json # Just for editor support, not for building
No build step, dual interface (CLI + library), full TypeScript types for consumers, and exactly ONE dependency (@google/genai). That’s it.
If you’re still on Node for your internal tools and CLIs — just try bun init on your next module. No tsconfig.json tweaking, no type: "module" vs "commonjs" saga, no waiting 10 seconds for a script to start. The whole image-gen module is 90 lines across 4 files, works as both a CLI and a library, and took about 20 minutes to write.
Stop wasting time on build tooling that should’ve been solved a decade ago.