Cover image for TypeScript Project Setup in 2026: Bun, Biome, and Vitest (A Complete Stack)

At a glance

Reading time

~200 words/min

Published

1 day ago

May 5, 2026

Views

16

All-time total

TypeScript Project Setup in 2026: Bun, Biome, and Vitest (A Complete Stack)

For most of the last decade, starting a TypeScript project meant assembling six tools : npm, ts-node, ESLint, Prettier, Jest, ts-node-dev wiring them together with a config file each, and praying nothing hit a peer-dependency conflict by Friday. In 2026 you can do better. Bun handles the runtime, the package manager, and the test runner. Biome handles linting and formatting in one binary. Vitest covers the cases Bun's test runner doesn't. The whole toolchain fits in three dependencies and starts up in milliseconds.

 

This post sets up a project end-to-end the way I'd start one today, with the trade-offs of each choice spelled out so you can swap pieces if your team has constraints.

The shape of the stack

  • Bun : runs TypeScript natively (no transpile step), installs packages 10-30× faster than npm, and includes a test runner and bundler.
  • Biome : a Rust-based linter + formatter that replaces ESLint and Prettier with a single binary and one config file.
  • Vitest : fast, Jest-compatible test runner with first-class TypeScript support. Use it when you need features Bun's runner doesn't have yet (browser-like environments, snapshot testing).
  • tsc : still the source of truth for type checking. Bun and Biome don't replace it.

Project bootstrap

curl -fsSL https://bun.sh/install | bash    # install Bun if needed

mkdir my-app && cd my-app
bun init -y                                    # creates package.json, tsconfig.json, index.ts
bun add -d @biomejs/biome vitest @types/bun
bunx @biomejs/biome init

That's the entire setup. No npm install eslint prettier jest ts-node ..., no .eslintrc, no .prettierrc.

A modern tsconfig.json

Bun's default tsconfig.json is fine but worth tightening. Here's what I run with:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "Preserve",
    "moduleResolution": "bundler",
    "lib": ["ESNext"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "exactOptionalPropertyTypes": true,
    "verbatimModuleSyntax": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "types": ["bun-types"]
  },
  "include": ["src/**/*", "tests/**/*"]
}

The four flags worth mentioning:

  • noUncheckedIndexedAccessarr[0] is typed as T | undefined, not T. Catches a real class of bug at the cost of a few more ifs.
  • exactOptionalPropertyTypes : distinguishes "missing" from "explicitly undefined". Useful when wrapping APIs that treat them differently.
  • verbatimModuleSyntax : forces explicit import type for type-only imports. Pairs well with bundlers that strip imports based on this signal.
  • noEmit : Bun does the running. tsc only type-checks.

Biome: lint and format in one config

The default biome.json after biome init is reasonable. Tweak to taste:

{
  "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
  "organizeImports": { "enabled": true },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "complexity": { "noBannedTypes": "error" },
      "suspicious": { "noExplicitAny": "warn" }
    }
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100,
    "lineEnding": "lf"
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "trailingCommas": "all",
      "semicolons": "always"
    }
  }
}

Run it from package.json scripts:

{
  "scripts": {
    "dev":     "bun --watch src/index.ts",
    "build":   "bun build src/index.ts --outdir dist --target node",
    "check":   "biome check --write .",
    "lint":    "biome lint .",
    "format":  "biome format --write .",
    "test":    "bun test",
    "test:ui": "vitest",
    "type":    "tsc --noEmit"
  }
}

Bun's built-in test runner

// src/sum.ts
export const sum = (a: number, b: number) => a + b;
// tests/sum.test.ts
import { describe, expect, test } from 'bun:test';
import { sum } from '../src/sum';

describe('sum', () => {
  test('adds two positive numbers', () => {
    expect(sum(2, 3)).toBe(5);
  });

  test('handles negatives', () => {
    expect(sum(-2, 3)).toBe(1);
  });
});
bun test
# bun test v1.x.x
# 2 pass / 0 fail / ran in 21ms

21ms including process start. That's faster than Jest's banner takes to print. For unit tests of pure code, Bun is the right pick.

When to reach for Vitest instead

Bun's test runner is excellent but still missing a few things many real projects need: a polished JSDOM environment, browser mode via Playwright, and the deeper Jest-API surface. When any of those matter, Vitest is the call:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    coverage: { reporter: ['text', 'html'] },
  },
});

Mix and match: keep bun test for the bulk of unit tests, run Vitest only for the suites that need a DOM or browser environment. Both can coexist.

CI: one command, no Node

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v1
        with: { bun-version: latest }
      - run: bun install --frozen-lockfile
      - run: bun run check
      - run: bun run type
      - run: bun test

No setup-node, no separate ESLint/Prettier steps. One install, three checks. CI runs in well under a minute on a typical small project.

When this stack isn't right

  • You're shipping a library consumed by Node-only users : Bun runtime APIs (Bun.file, Bun.serve) won't be available to your consumers. Avoid them and prefer Web standards.
  • Your team relies on a specific ESLint plugin Biome doesn't yet have a port for. Biome's rule coverage is excellent for the common cases but not 1:1 with the ESLint ecosystem.
  • You ship to AWS Lambda's Node runtime : you'll still want to test on real Node before deploying. Build with Bun, test with both.

Why this matters

None of these tools are revolutionary in isolation. The win is the combination: fewer dependencies, fewer config files, faster install, faster test, faster CI. The hours you used to spend yak-shaving the toolchain go back into the actual project. After three months on this setup the difference is hard to overstate every other Node project I open feels like it boots up in molasses.

The best toolchain is the one you can hold entirely in your head. Bun + Biome + Vitest is small enough to do that.

Start a side-project with this stack this week. Once you feel the speed, going back to the old setup gets harder by the day.

Newsletter

Want more posts like this?

Get practical software notes and tutorials delivered when something new is published.

No spam. Unsubscribe anytime.

Share

Related posts

Next.js 15 Server Actions vs API Routes: When to Use Each (2026 Decision Guide)

A practical decision tree for Next.js 15, when Server Actions beat API Routes, when they don't, and how to migrate cleanly. With code, security, and performance notes.

6 days ago

Vue 3.5 Vapor Mode: Performance Wins, Migration Notes, and a Real Benchmark

A complete look at Vue 3.5 Vapor Mode, what it actually changes, how to enable it per file or project-wide, what works, what doesn't, and real benchmark numbers.

9 hours ago

Building Type-Safe APIs with Node.js, Hono, and Drizzle ORM (Full Walkthrough)

A complete walkthrough of building a type-safe Node.js API with Hono and Drizzle for schema, migrations, typed routes, RPC client, and edge deployment.

2 days ago