← All posts
·16 min read

Claude Code with Bun: Runtime, Tests, Package Workflow

Claude CodeBunRuntimeWorkflow
Claude Code with Bun: Runtime, Tests, Package Workflow

Why Bun needs a project-specific CLAUDE.md

Bun is unusual in the JavaScript ecosystem because it replaces four tools at once. It is the runtime, the package manager, the test runner, and the bundler. Most projects historically pick one tool per slot: Node for runtime, npm or pnpm for packages, Vitest or Jest for tests, esbuild or Rollup for bundling. Bun collapses that into a single binary with native APIs that outperform their Node equivalents on most workloads.

Claude Code understands Bun. It knows Bun.serve, Bun.file, bun test, bun install, the bun --hot flag, and the differences between bun.lockb and package-lock.json. What it does not know is whether your project actually wants the Bun-native API or the Node-compatible fallback for any given task. Bun ships with broad Node compatibility by design, which means the wrong code still runs. require, process.env.PORT, fs.promises.readFile, and Express servers all work. They just leave performance on the table and confuse future readers about what runtime this project targets.

Without a project-specific CLAUDE.md, Claude regularly generates code that is technically correct but architecturally wrong for a Bun project. It pulls in express instead of using Bun.serve. It uses fs.promises.readFile instead of Bun.file().text(). It writes Jest configs for a project that should be running bun test. The build passes, the tests pass, and the team ends up with a hybrid codebase that is half Node and half Bun.

This guide covers the CLAUDE.md rules and code patterns that make Claude Code reliable for Bun. If you are new to Claude Code, the Claude Code setup guide covers installation and authentication first.

The Bun CLAUDE.md template

The CLAUDE.md at your project root is read before every Claude Code session. For a Bun project, it needs to answer: which Bun version, which features are in use (server, test runner, bundler), what the TypeScript setup looks like, and which Node-isms are explicitly banned in favour of native APIs.

# Bun project rules

## Stack
- Bun: 1.2.x (pinned in package.json engines)
- TypeScript: 5.6.x with strict mode (Bun runs .ts directly, no build step in dev)
- Server framework: native Bun.serve (no Express, no Fastify, no Hono unless explicitly added)
- Test runner: bun test (no Jest, no Vitest)
- Bundler: bun build (only for production output, dev runs .ts directly)
- Package manager: bun install (no npm, pnpm, or yarn in this project)
- Lockfile: bun.lockb (binary, committed to git)

## Project structure
- src/: application source (TypeScript, runs directly via bun)
- src/server.ts: Bun.serve entry
- src/routes/: route handlers, exported as Bun.serve handlers
- src/lib/: shared utilities
- tests/: bun test specs (.test.ts suffix)
- bunfig.toml: Bun config (test, install, run sections)
- tsconfig.json: TypeScript config (target ESNext, moduleResolution bundler)

## Bun-native API rules
- HTTP server: ALWAYS Bun.serve, never node:http or Express
- File reads: ALWAYS Bun.file(path).text() / .json() / .arrayBuffer(), never fs.readFile
- File writes: ALWAYS Bun.write(path, data), never fs.writeFile
- Hashing: ALWAYS Bun.password.hash / Bun.CryptoHasher, never node:crypto for password work
- Subprocess: ALWAYS Bun.spawn, never node:child_process.spawn
- Env vars: process.env still works, but prefer Bun.env for clarity in new code
- Glob: ALWAYS Bun.Glob, never glob package
- SQLite: ALWAYS bun:sqlite, never better-sqlite3 unless platform requires it

## Running the project
- Dev: `bun --hot src/server.ts` (hot reload, not full restart)
- Production: `bun src/server.ts` (no --hot, no --watch)
- Test: `bun test` (watch mode is `bun test --watch`)
- Test (CI): `bun test --coverage` (single run with coverage report)
- Build: `bun build src/server.ts --outdir dist --target bun` (only for deploy artifacts)
- Install: `bun install` (respects bun.lockb)
- Add dep: `bun add <pkg>` (production) or `bun add -d <pkg>` (dev)

## Hard rules
- NEVER add express, fastify, koa, or any Node HTTP framework. Use Bun.serve.
- NEVER use require(). ESM imports only.
- NEVER add jest, ts-jest, vitest, or any other test runner. Use bun test.
- NEVER edit bun.lockb manually. Run bun install to regenerate.
- NEVER commit node_modules. .gitignore covers it.
- NEVER mix bun and npm in the same project. The lockfiles will fight.

Three rules in this template prevent the most common Claude Code failures.

The HTTP framework rule is the load-bearing one. Claude has a strong prior toward Express because most of its training data is Express code. Without an explicit rule, asking Claude to "add a route" in a Bun project produces an express() import even when Bun.serve is already set up next door. The fix is to ban Express by name and provide a Bun.serve example for Claude to extend.

The test runner rule prevents a similar default. Jest is the bulk of test code on the internet, and Claude reaches for it on autopilot. bun test has Jest-compatible globals (describe, test, expect) so the test files themselves look familiar, but the runner config and the way you run tests is different. An explicit ban stops Claude from generating a jest.config.js and a vitest.config.ts alongside your existing bun test setup.

The lockfile rule matters because bun.lockb is binary. Unlike package-lock.json, you cannot eyeball a diff. If Claude regenerates it accidentally with a different Bun version, you get a different dependency tree without any visible signal. Treating bun.lockb as machine-only and gating bun install --no-save patterns prevents quiet drift.

For TypeScript-focused configuration, the Claude Code TypeScript guide covers tsconfig settings that pair with Bun's moduleResolution: "bundler" setup.

Bun-native APIs vs Node compatibility

Bun's compatibility shim is one of its strongest selling points and one of the most reliable ways for Claude to drift toward worse code. Almost every Node API works under Bun. fs.promises, node:crypto, node:http, node:child_process, and the rest run unmodified. The runtime simply implements them on top of its own primitives.

The problem is that for most of those APIs, Bun has a native equivalent that is faster, simpler, and TypeScript-first. The Bun.file() API for reads is two to four times faster than fs.promises.readFile on most file sizes. Bun.write() is similar. Bun.serve() benchmarks at three to five times the throughput of an equivalent node:http server. Bun.spawn has cleaner ergonomics than child_process.spawn. bun:sqlite is faster than better-sqlite3 and ships in the runtime with no native compilation step.

Claude defaults to the Node API roughly half the time without explicit rules. The fix is to put the native API in CLAUDE.md and provide a reference implementation for each common case.

// src/server.ts
import { type Server } from 'bun'

const server: Server = Bun.serve({
  port: process.env.PORT ?? 3000,
  hostname: process.env.HOST ?? '0.0.0.0',

  async fetch(req, server) {
    const url = new URL(req.url)

    if (url.pathname === '/health') {
      return new Response('ok', { status: 200 })
    }

    if (url.pathname === '/upload' && req.method === 'POST') {
      const data = await req.arrayBuffer()
      await Bun.write(`./uploads/${crypto.randomUUID()}`, data)
      return Response.json({ saved: true })
    }

    if (url.pathname === '/file') {
      const file = Bun.file('./public/index.html')
      if (!(await file.exists())) {
        return new Response('not found', { status: 404 })
      }
      return new Response(file)
    }

    return new Response('not found', { status: 404 })
  },

  error(error) {
    console.error('server error:', error)
    return new Response('internal error', { status: 500 })
  },
})

console.log(`listening on ${server.url}`)

A few elements are worth highlighting because Claude reproduces them once they are present.

The fetch handler signature uses standard Request and Response from the Web Fetch API, not Node's IncomingMessage and ServerResponse. This makes the same handler shape portable to Cloudflare Workers, Deno, and edge runtimes. Claude generates handlers in this style when an example exists; without one, it falls back to Express-style (req, res) patterns that do not work in Bun.serve.

The file response pattern (new Response(file)) is Bun-specific and important. Passing a BunFile directly to Response streams the file with zero-copy semantics where possible. The naive Node port (fs.readFile then send) loads the entire file into memory before responding. This matters for any project serving uploads, static assets, or generated artifacts.

The error handler is structural in Bun.serve. It captures any unhandled throw inside fetch and returns a sensible response rather than killing the server. Without this, Claude tends to wrap every route in a try/catch, which is verbose and easy to forget. Centralising error handling at the server level keeps route code clean.

For environment configuration, the Claude Code environment variables guide covers .env patterns that work the same way in Bun as in Node.

Package management workflow

Bun's package manager is the reason a lot of teams switch even before they care about the runtime. bun install is roughly twenty times faster than npm install for cold installs and three to five times faster than pnpm install. It uses a binary lockfile (bun.lockb) that is more compact than package-lock.json and faster to parse.

The workflow rules differ from npm in a few places that matter when Claude is generating commands.

# Install all dependencies (uses bun.lockb)
bun install

# Add a production dependency
bun add zod

# Add a dev dependency
bun add -d @types/bun

# Add a peer dependency
bun add -p react

# Update a single package
bun update zod

# Update all packages within their semver ranges
bun update

# Remove a package
bun remove zod

# Run a script from package.json
bun run dev

# Or shorthand for any "scripts" entry
bun dev

# Frozen install (CI, fails if lockfile would change)
bun install --frozen-lockfile

# Print outdated packages
bun outdated

The shorthand bun dev (without run) is unique to Bun and worth calling out in CLAUDE.md. Any script in package.json can be invoked directly. This works for bun test, bun start, bun build, and any custom scripts. Claude picks this up from examples but defaults to bun run if it has not seen the shorthand in a session.

The lockfile rules matter for Claude sessions specifically. bun.lockb is binary. Claude cannot read it the way it can read package-lock.json, which means Claude cannot diff dependency changes by inspecting the file. Add a CLAUDE.md note that any dependency change should produce a bun outdated or bun install --dry-run output for the operator to review. This replaces the visual lockfile diff from the npm world.

## Lockfile workflow

- bun.lockb is binary, committed to git, never edited by hand
- After any `bun add`, `bun update`, or `bun remove`: run `bun outdated` to confirm the new tree
- For CI, always use `bun install --frozen-lockfile` to fail builds if the lockfile would change
- Never run `bun install --no-save` in normal development. It updates node_modules without updating the lockfile
- If `bun.lockb` is missing or corrupted, delete it and run `bun install` to regenerate
- Switching from npm/pnpm: delete package-lock.json or pnpm-lock.yaml first, then run `bun install`

The migration rule is the one that catches teams in the first week. If you bun install over an existing package-lock.json, Bun reads it for compatibility but the resulting bun.lockb may diverge from what pnpm install would produce. Cleanest path: delete the old lockfile, then run bun install, then commit both the deletion and the new bun.lockb in the same change.

For monorepo workflows, the Claude Code monorepo guide covers workspace patterns. Bun supports workspaces natively via package.json workspaces field, with the same syntax as npm and pnpm.

Bun.serve and the development workflow

The dev workflow under Bun is simpler than under Node because there is no separate process for transpilation, watch, or restart. bun --hot src/server.ts runs TypeScript directly with hot reload of route handlers. There is no nodemon, no tsx, no ts-node, no concurrently script gluing watchers to compilers.

The flag matters. bun --hot is hot module replacement: it swaps changed modules without restarting the process, preserving in-memory state where possible. bun --watch is a full restart on every file change, equivalent to nodemon. For most server work, --hot is what you want. For scripts that should reset state between runs (cron jobs, batch processors), --watch is correct.

# Development with hot reload (preserves state)
bun --hot src/server.ts

# Development with full restart on change
bun --watch src/server.ts

# Production (no reloading)
bun src/server.ts

# Run a one-off script
bun src/scripts/migrate.ts

# Run with a specific .env file
bun --env-file=.env.local src/server.ts

The --env-file flag replaces dotenv for most purposes. Bun reads .env, .env.local, .env.production automatically based on NODE_ENV, with the same precedence rules as Next.js and Vite. There is no need to add the dotenv package or call dotenv.config() at the top of your entry. Claude tends to add dotenv reflexively because of its Node training; an explicit CLAUDE.md note prevents this.

Add a dev workflow section to your CLAUDE.md:

## Dev workflow

- ALWAYS run dev with `bun --hot src/server.ts`, never plain `bun src/server.ts`
- ALWAYS use Bun.serve for HTTP, never node:http, http, or any framework
- ALWAYS exit dev with Ctrl-C; the server will close cleanly via Bun.serve's stop()
- DO NOT add nodemon, tsx, ts-node, concurrently, or dotenv
- DO NOT run a separate TypeScript compiler. Bun runs .ts directly
- Port: read from process.env.PORT, default 3000. Document the default in CLAUDE.md
- HMR caveat: Bun.serve handlers reload, but database connections and intervals do not. Keep these in modules that the route file imports, not in the route file itself

The HMR caveat is structural and matters for any project with persistent connections. If you open a database pool inside src/server.ts, hot reload will create a new pool every time the file changes, leaking connections until the dev process is killed. The fix is to put the pool in src/lib/db.ts and import it into the server. Bun's HMR knows the difference between a module that has changed and a module that is unchanged via import graph analysis, so the pool stays alive.

For database-specific patterns, connection lifetime rules apply across runtimes regardless of whether you choose Bun or Node.

Test runner: bun test

bun test is Jest-compatible at the API level. The same describe, test, expect, beforeAll, afterEach, and mock calls work. The runner is significantly faster than Jest or Vitest on most workloads because it shares the runtime process and skips the transpilation step.

// tests/auth.test.ts
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
import { startTestServer, stopTestServer } from './helpers/server'

let serverUrl: string

beforeAll(async () => {
  serverUrl = await startTestServer()
})

afterAll(async () => {
  await stopTestServer()
})

describe('auth', () => {
  test('rejects requests without a token', async () => {
    const res = await fetch(`${serverUrl}/api/me`)
    expect(res.status).toBe(401)
  })

  test('accepts valid bearer tokens', async () => {
    const res = await fetch(`${serverUrl}/api/me`, {
      headers: { Authorization: 'Bearer test-token' },
    })
    expect(res.status).toBe(200)
    const body = await res.json()
    expect(body).toMatchObject({ id: expect.any(String) })
  })

  test('hashes passwords with Bun.password', async () => {
    const hash = await Bun.password.hash('hunter2')
    const ok = await Bun.password.verify('hunter2', hash)
    expect(ok).toBe(true)
  })
})

The import is from 'bun:test', not from '@jest/globals' or from 'vitest'. Claude often produces the wrong import on the first attempt; the rule in CLAUDE.md fixes this. The bun:test namespace is built into the runtime, so there is nothing to install.

bun test automatically discovers files matching *.test.ts, *.test.tsx, *.test.js, *.test.jsx, *.spec.ts, *.spec.tsx, *.spec.js, and *.spec.jsx anywhere in the project. There is no test config to set the discovery pattern unless you want to override it. For projects that need specific include/exclude rules, configure in bunfig.toml:

# bunfig.toml
[test]
preload = ["./tests/setup.ts"]
coverage = true
coverageThreshold = { line = 0.8, function = 0.8 }
coverageReporter = ["text", "lcov"]
coverageDir = "./coverage"

[install]
exact = false
production = false
peer = true
saveTextLockfile = false

The preload array runs files before any test, equivalent to Jest's setupFiles. Use this for global mocks, polyfills, or test-only environment setup. Claude generates this correctly when the file path is in CLAUDE.md and incorrectly when it has to guess (it sometimes produces setupFilesAfterEach from Jest memory).

The coverage configuration is worth pinning because the defaults change across Bun versions. Setting explicit thresholds and reporters in bunfig.toml means CI fails predictably and Claude does not invent flag combinations on the command line.

For broader test patterns, the Claude Code testing guide covers organisation, mocking strategies, and what to test versus what to leave alone.

Permission hooks for Bun projects

Bun projects have a small set of safe commands and a small set that should be gated. Dev, test, build, and read-only package operations are safe. Anything that modifies the lockfile, publishes, or wipes node_modules should require approval.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(bun*)",
      "Bash(bun run*)",
      "Bash(bun test*)",
      "Bash(bun build*)",
      "Bash(bun --hot*)",
      "Bash(bun --watch*)",
      "Bash(bun outdated*)",
      "Bash(bun install --frozen-lockfile*)"
    ],
    "deny": [
      "Bash(bun install --no-save*)",
      "Bash(bun publish*)",
      "Bash(bun pm trust*)",
      "Bash(bun x rm -rf*)",
      "Bash(rm -rf node_modules*)",
      "Bash(rm bun.lockb*)"
    ]
  }
}

The bun install --no-save deny is the load-bearing one. Plain bun install respects the lockfile. Adding --no-save updates node_modules without updating bun.lockb, which means the next teammate who runs bun install gets a different tree from yours. Gating this command means Claude cannot accidentally introduce drift.

The bun pm trust deny prevents lifecycle scripts (postinstall, preinstall) from running on packages that have not been explicitly trusted. Bun, like pnpm, blocks these scripts by default for security. Trusting a package should be a deliberate decision, not something Claude does in passing.

The bun publish deny is a safety net for library projects: no automated session pushes a release.

For permission patterns across project types, the Claude Code best practices guide covers principles regardless of runtime.

Bundling for production

Bun's bundler is the slot most teams use last, partly because the runtime works fine without bundling for server projects. You generally only need bun build if you are deploying to a target that does not run Bun (Cloudflare Workers, AWS Lambda with Node) or shipping a library to npm.

# Bundle for the Bun runtime (single-file, fast startup)
bun build src/server.ts --outdir dist --target bun --minify

# Bundle for Node compatibility
bun build src/server.ts --outdir dist --target node

# Bundle for the browser
bun build src/main.ts --outdir dist --target browser --minify --splitting

# Bundle as a standalone executable
bun build src/cli.ts --compile --outfile dist/cli

The --target flag matters because it changes how Bun-specific APIs are handled. With --target bun, calls to Bun.file and Bun.serve are preserved as-is. With --target node, those calls would fail at runtime, so the bundler should reject the build (or you should refactor before bundling for Node).

The --compile flag produces a standalone executable with the Bun runtime embedded. For CLI tools, this is the cleanest distribution path: a single binary, no npx, no Node version requirements on the user's machine. The output is around 50MB depending on the project, which is large for npm but small for distribution outside it.

For deploy-target specifics, the Claude Code deploy guide covers platform adjustments that pair with these bundle settings.

What Claude Code handles well and where to review

Claude Code performs reliably in several Bun areas. Bun.serve route handlers are correct when an existing route file is present for pattern matching. Test files use the right bun:test imports when CLAUDE.md bans Jest. bun add and bun remove commands run cleanly. Hot reload setup with bun --hot is generated correctly.

Three areas warrant manual review. The first is the Node-isms problem: Claude periodically reaches for fs.promises.readFile or node:http even with rules in place, especially in longer sessions where the session context is heavier than the project context. Search for fs.readFile, fs.writeFile, require(, and from 'http' in any new code. The second is dependency choice: Claude defaults to popular packages (express, axios, dotenv, jest) without checking whether Bun has a native equivalent. Review every bun add for whether the package is necessary. The third is HMR-sensitive code: route handlers reload cleanly, but database pools, schedule intervals, and websocket servers do not. Make sure long-lived state lives outside the file you edit during dev.

For debugging when something breaks, the Claude Code debugging guide covers approaches that work across runtimes.

Hard rules summary

The patterns above reduce to a list of mandatory rules that belong at the top of every Bun CLAUDE.md:

  1. ESM only. No require(). Bun supports both, but the project standard is one or the other.
  2. Bun.serve for HTTP. No Express, Fastify, or node:http imports.
  3. Bun.file and Bun.write for file I/O. No fs.promises.readFile or fs.writeFile.
  4. bun test for tests. No Jest, Vitest, or ts-jest dependencies.
  5. bun install for packages. No npm or pnpm. Delete competing lockfiles.
  6. bun --hot for dev. No nodemon, tsx, ts-node, or concurrently.
  7. bun.lockb is binary, committed, never edited by hand.
  8. Long-lived state (DB pools, intervals) lives in modules outside the hot-reloading entry.
  9. Deny bun install --no-save, bun publish, and bun pm trust in permission hooks.
  10. Verify --target matches the deploy environment when running bun build.

These rules prevent the most common Bun failures in Claude Code sessions. They produce a server that handles request load at native speed, a test suite that runs in a single process, a dependency tree that does not drift, and a dev loop with no transpiler in the way.

The deeper benefit is that Bun rewards consistency. A codebase that is fully Bun-native is faster, smaller, and easier to reason about than one that mixes Node and Bun APIs. The CLAUDE.md rules above are the mechanism that keeps the codebase in the Bun-native lane through every Claude Code session. Claudify includes a Bun-specific CLAUDE.md template pre-configured for Bun 1.2, the native API patterns above, and the permission hooks that gate destructive package operations. For the broader role of CLAUDE.md across project types, CLAUDE.md explained covers how the file is read and how to structure it for any stack.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir