Skip to content
Homeβ€Ί ML / AIβ€Ί My 2026 Developer Productivity Stack (Tools, Workflow & Hard Lessons)

My 2026 Developer Productivity Stack (Tools, Workflow & Hard Lessons)

Where developers are forged. Β· Structured learning Β· Free forever.
πŸ“ Part of: Tools β†’ Topic 11 of 11
The specific tools, configurations, and workflows I use daily in 2026 β€” with trade-offs, failure modes, and measured productivity gains across editor, AI assistant, runtime, monorepo, CI, database, and deployment.
βš™οΈ Intermediate β€” basic ML / AI knowledge assumed
In this tutorial, you'll learn
The specific tools, configurations, and workflows I use daily in 2026 β€” with trade-offs, failure modes, and measured productivity gains across editor, AI assistant, runtime, monorepo, CI, database, and deployment.
  • Productivity is measured by time-to-merge β€” not lines of code, not tool count, not hours spent. If a tool does not reduce time-to-merge, remove it.
  • AI assistants generate tests that reproduce the same logic errors as the code they test β€” boundary conditions require manual test cases written against the specification, not the implementation.
  • Local-first tools (Biome, Bun, Turborepo remote caching) eliminate CI round-trips for checks that should run in under three seconds before every commit.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
⚑Quick Answer
  • Productivity in 2026 is measured by time-to-merge, not tool count β€” every tool either compresses that timeline or adds friction
  • This stack (Neovim, Cursor + Claude, Bun, Turborepo, Biome, Drizzle, Vercel) runs a B2B SaaS platform with 80K+ daily active users across two engineers
  • Biggest risk: AI assistants generate tests that reproduce the same logic errors as the code they test β€” boundary conditions require manual test cases
Production IncidentAI-Generated Tests Reproduced the Same Logic Error as AI-Generated CodeA fintech team using an AI coding assistant shipped a reconciliation module with a boundary condition error that misallocated transactions over 11 days before detection.
SymptomMonthly reconciliation reports showed a six-figure discrepancy between expected and actual fund allocations. No error logs. No exceptions thrown. The system appeared healthy across all monitoring dashboards.
AssumptionThe team assumed the discrepancy was caused by an upstream API returning stale data during a known maintenance window two weeks earlier.
Root causeAn AI coding assistant generated a date-range filter for the reconciliation query. The generated code used exclusive end-date comparison (date < endDate) instead of inclusive (date <= endDate). The engineer reviewed the code and approved it. The AI-generated test suite also used the same off-by-one boundary β€” both production code and tests agreed on the wrong behavior. The tests passed. The reviews passed. The bug shipped.
FixAdded boundary-condition test cases written manually by engineers based on the business specification β€” not generated by AI and not derived from the implementation. Added a reconciliation checksum that compares total allocated versus total received at the batch level before committing any batch. Added a daily automated alert for any allocation discrepancy exceeding a defined threshold.
Key Lesson
AI-generated tests validate the implementation, not the specification β€” they will reproduce the same logic errors as the code they test because they derive from the same mental modelBoundary conditions β€” date ranges, pagination limits, off-by-one arithmetic, inclusive versus exclusive comparisons β€” require manual test cases written against the business rule, not the codeFinancial and reconciliation systems need independent checksums at every aggregation boundary β€” the correctness of the code is not sufficient evidence that the output is correctAn AI assistant that generates both the code and the tests for the same feature provides zero independent verification β€” the reviewer is the only independent check
Production Debug GuideWhen your tools are slowing you down instead of speeding you up
AI assistant suggestions require heavy editing on every completion→Add or update your .cursorrules file with project-specific conventions, anti-patterns, and architecture decisions. Generic context produces generic code. Reference your actual type files and hook patterns explicitly in the rules.
Local development environment takes more than 30 seconds to start→Profile dev server startup. If webpack is the bottleneck, migrate to Next.js with Turbopack (enabled by default in Next.js 15+). If dependency install is the bottleneck, switch from npm or pnpm to Bun. If database startup is the bottleneck, replace local Docker Postgres with a Neon development branch.
CI pipeline takes more than 5 minutes for a typical PR→Move lint, format, and type-check to pre-commit hooks. Add Turborepo remote caching — unchanged packages should restore from cache, not rebuild. Profile which CI job is the bottleneck: if it is unit tests, check for missing test isolation causing sequential runs; if it is E2E, parallelize across browser workers.
New engineers take more than one day to set up the local environment→Your setup documentation is missing or outdated. Create a single bun run setup command that handles everything: install dependencies, run database migrations, seed development data, copy environment variable templates. Test it on a fresh machine or clean Docker container monthly.
Context switching between projects kills flow state→Automate session setup with tmux session scripts. One command should create or reattach to a project session with editor, dev server, git, and logs preconfigured. Context switching should take five seconds, not five minutes.
Turborepo cache is serving stale build artifacts→Check your turbo.json inputs definition for the failing task. Any file that affects the output must be listed as an input. Run turbo build --dry to see what the cache key includes. If the task produces non-deterministic output (timestamps, random IDs), it cannot be cached — set cache: false for that task.
Drizzle migration fails in CI but passes locally→Ensure CI is running migrations against a clean database branch, not a shared development database. Neon branch databases eliminate shared-state migration conflicts. Check that the migration file was committed — Drizzle generates migration SQL files that must be version controlled alongside schema changes.

Developer productivity in 2026 is defined by one metric: time from intent to deployed change. Every tool in your stack either compresses that timeline or adds friction. This article documents the specific combination I use daily across a B2B SaaS platform serving 80K+ daily active users β€” maintained by two engineers.

This is not a survey of every tool on the market. Tools that are not listed were evaluated and did not survive production use. Where relevant, I name them and explain why they were cut.

The stack covers seven layers: editor, AI coding assistant, runtime and package manager, monorepo and build system, formatting and linting, terminal workflow, CI/CD pipeline, database and ORM, and deployment and observability. Each layer has trade-offs documented, failure modes named, and configuration shown.

Common misconception: productivity means typing faster. It does not. Productivity means fewer decisions, fewer context switches, and fewer round-trips to CI for things that should have been caught locally.

One warning before the stack: the most expensive incident in the past two years came not from a tool failure but from an AI assistant failure. That incident shapes how every tool in this stack is used β€” it is documented first.

Editor: Neovim + LazyVim

Neovim with the LazyVim distribution is my primary editor. The decision is not about vim keybindings β€” it is about composability, startup speed, and terminal integration.

LazyVim provides a curated plugin ecosystem with sane defaults. LSP configuration via nvim-lspconfig, syntax highlighting via treesitter, and debug adapters work out of the box. Configuration is a Lua overlay on top of LazyVim's defaults β€” updates flow without merge conflicts between my customizations and upstream changes.

The key advantage over VS Code is context preservation. Neovim runs inside tmux sessions. Detaching from a session and reattaching from a different machine restores the exact state: open buffers, terminal output, unsaved changes. VS Code Remote SSH approximates this but adds round-trip latency for every keypress and requires a persistent server process on the remote machine.

Zed is worth watching β€” its performance on large codebases is comparable to Neovim, and its built-in AI features reduce the need for a separate AI assistant. I evaluated Zed for two weeks in Q1 2026 and returned to Neovim primarily because Zed's plugin ecosystem does not yet match nvim-lspconfig's language server coverage for the languages in this stack.

Editor choice is individual β€” it is not a team decision. Standardize the formatter and linter, not the editor.

~/.config/nvim/lua/plugins/editor.lua Β· LUA
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
return {
  {
    'LazyVim/LazyVim',
    opts = {
      colorscheme = 'tokyonight-storm',
    },
  },
  -- TypeScript language server
  {
    'neovim/nvim-lspconfig',
    opts = {
      servers = {
        -- ts_ls is the correct server name in nvim-lspconfig 2025+
        -- (renamed from tsserver in earlier versions)
        ts_ls = {
          settings = {
            typescript = {
              inlayHints = {
                includeInlayParameterNameHints = 'all',
                includeInlayFunctionParameterTypeHints = true,
                includeInlayVariableTypeHints = true,
              },
            },
          },
        },
        -- Biome as LSP for lint diagnostics inline
        biome = {},
      },
    },
  },
  -- Format on save via Biome
  {
    'stevearc/conform.nvim',
    opts = {
      formatters_by_ft = {
        typescript = { 'biome' },
        typescriptreact = { 'biome' },
        javascript = { 'biome' },
        javascriptreact = { 'biome' },
        json = { 'biome' },
        jsonc = { 'biome' },
      },
      -- Format on save, but not if save is triggered by autoread
      format_on_save = { timeout_ms = 500, lsp_fallback = true },
    },
  },
  -- Git integration
  {
    'lewis6991/gitsigns.nvim',
    opts = {
      signs = {
        add = { text = '+' },
        change = { text = '~' },
        delete = { text = '_' },
      },
    },
  },
}
Mental Model
Editor Selection Framework
The best editor is the one that disappears β€” you stop thinking about the tool and think only about the problem.
  • Startup speed matters when you open the editor 50+ times per day β€” Neovim opens in under 50ms; VS Code takes 1-3 seconds
  • Terminal integration matters when your workflow includes SSH sessions, remote log tailing, and database CLI access
  • Plugin composability matters when your stack changes quarterly β€” swap LSP servers and formatters without rewriting config
  • Onboarding cost is real β€” if your team cannot set up the editor in under 10 minutes, editor standardization is a team-wide tax
  • Standardize the formatter and linter configuration, not the editor β€” Biome's output is identical regardless of which editor runs it
πŸ“Š Production Insight
Neovim config drift across team members creates inconsistent tooling behavior. Shared formatter configs (biome.json) are more important than shared editor configs. One engineer using VS Code and one using Neovim produce identical formatted output when both run Biome β€” the editor is irrelevant to code quality.
🎯 Key Takeaway
Neovim's advantage is composability and terminal integration, not vim keybindings. Editor choice is personal β€” formatter and linter configuration is a team decision. If your editor config exceeds 200 lines, you are configuring more than you are coding.

AI Coding Assistant: Cursor + Claude

Cursor with Claude integration is the primary AI coding assistant. The critical differentiator over GitHub Copilot is context management: Cursor indexes the entire codebase and allows explicit file references in chat. Agent mode handles multi-file refactoring in a single session β€” rename a hook, update all call sites, generate updated tests, and update the Storybook story without switching windows.

The production incident above changed how AI assistance is used. The key insight: AI generates tests that reproduce the same logic errors as the code they test. When AI writes both the implementation and the tests for the same feature, you have zero independent verification β€” the engineer's review is the only check. That is not enough for business-critical logic.

I use AI assistants for four categories: boilerplate generation (CRUD operations, type definitions, component scaffolding), refactoring (rename across files, extract functions, update import paths), code explanation (what does this function do, what are the edge cases), and test structure generation (scaffold the test file, write the describe blocks β€” humans write the assertions for business logic).

I do not use AI for: architecture decisions, security-sensitive logic (authentication, authorization, encryption), financial calculations or aggregations, boundary-condition tests, or any code I cannot explain to a teammate without reading it.

.cursorrules Β· MARKDOWN
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
# Project Conventions for AI Assistance
# Last updated: 2026-04-14
# These rules apply to all AI-generated code in this repository

## What AI should generate
- Boilerplate: CRUD operations, type definitions, component shells
- Refactoring: renames, extractions, import path updates
- Test structure: describe blocks, test names, mock setup
- Documentation: JSDoc comments, README sections

## What AI must NOT generate without explicit human review
- Financial calculations, aggregations, or reconciliation logic
- Authentication or authorization logic
- Boundary conditions in date ranges, pagination, or numeric comparisons
- Database migration files β€” generate the schema change, human writes the migration
- Security-sensitive operations: encryption, token validation, input sanitization

## Code Style
- TypeScript strict mode β€” no `any` types, no `as` type assertions without a comment
- Use Zod for runtime validation at all API boundaries (Server Actions, API routes)
- Prefer `async/await` over `.then()` chains
- Return Result types for errors in library code β€” never throw except in React error boundaries
- Named exports only β€” no default exports

## Architecture
- React Server Components by default β€” add 'use client' only for interactivity or browser APIs
- Server Actions for all mutations β€” no REST endpoints for internal operations
- Database access through repository functions in src/db/repositories/ β€” no Drizzle queries in components
- Environment variables accessed only through src/env.ts (validated with Zod at startup)

## Testing
- Vitest for unit and integration tests β€” colocated at src/**/*.test.ts
- Playwright for E2E β€” one test file per critical user journey in e2e/
- AI generates test structure (describe, it, beforeEach) β€” humans write business logic assertions
- Boundary conditions MUST be written manually: date ranges, pagination edges, null/undefined handling

## Import conventions
- Import from barrel exports in src/components/ui β€” not direct file paths
- Import types with `import type` β€” enforced by Biome
- No barrel exports (index.ts) for internal modules β€” import directly from source

## Anti-patterns β€” flag and reject
- useEffect for data fetching β€” use Server Components or use() hook
- Raw Drizzle queries in React components β€” use repository functions
- Hardcoded color values in Tailwind β€” use semantic tokens from globals.css @theme
- forwardRef wrappers β€” ref is a standard prop in React 19
⚠ AI Assistant Anti-Patterns
πŸ“Š Production Insight
In our measurement across a three-month period, Cursor reduced boilerplate scaffolding time by roughly 50% and increased review time for complex logic by roughly 20%. Net productivity gain is real but smaller than marketing claims suggest. Track time-to-merge, not lines generated β€” time-to-merge is the only metric that reflects actual throughput.
🎯 Key Takeaway
AI assistants are best for boilerplate and refactoring β€” worst for architecture, security, and financial logic. The .cursorrules file is the highest-leverage configuration in the AI workflow. If you cannot explain what the AI wrote without reading it, do not ship it.

Runtime and Package Manager: Bun

Bun has replaced Node.js as the primary runtime and npm as the package manager. The switch was driven by three concrete improvements measured on our monorepo: install speed, test execution speed, and startup time.

Package installation with Bun is 5-15x faster than npm on cold installs with no cached lockfile, and 3-5x faster than pnpm. On a monorepo with 400+ dependencies, bun install runs in under 10 seconds versus 90+ seconds with npm. Combined with Turborepo remote caching, unchanged packages are never reinstalled.

Bun's test runner executes Vitest-compatible tests 2-3x faster than Node.js on our test suite. The native TypeScript transpiler eliminates the compilation step for test execution. In watch mode, this is the difference between feedback in under one second versus two to four seconds β€” which affects how frequently you run tests.

The trade-off is ecosystem compatibility. Bun does not support all native Node.js C++ addons. In our stack, fewer than 5% of dependencies had Bun compatibility issues, all of which were resolved by the time we migrated in late 2025. We maintain a Node.js matrix job in CI to catch regressions against libraries with known compatibility history.

package.json Β· JSON
12345678910111213141516171819202122232425262728293031323334353637383940
{
  "name": "@acme/app",
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "test": "bun test",
    "test:watch": "bun test --watch",
    "test:coverage": "bun test --coverage",
    "test:node": "node --experimental-vm-modules node_modules/.bin/vitest run",
    "lint": "biome check --write .",
    "lint:ci": "biome ci .",
    "typecheck": "tsc --noEmit",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio",
    "db:seed": "bun run src/db/seed.ts",
    "setup": "bun install && bun run db:migrate && bun run db:seed && cp .env.example .env.local",
    "setup:verify": "bun run typecheck && bun run lint:ci && bun test"
  },
  "dependencies": {
    "next": "15.x",
    "react": "19.x",
    "react-dom": "19.x",
    "drizzle-orm": "latest",
    "@neondatabase/serverless": "latest",
    "zod": "^3",
    "@t3-oss/env-nextjs": "latest"
  },
  "devDependencies": {
    "@biomejs/biome": "latest",
    "typescript": "5.x",
    "drizzle-kit": "latest",
    "@playwright/test": "latest",
    "husky": "latest",
    "lint-staged": "latest",
    "commitlint": "latest",
    "@commitlint/config-conventional": "latest"
  }
}
πŸ’‘Bun Migration Strategy
  • Step 1 β€” Replace the package manager only: run bun install instead of npm install. Lowest risk, immediate gain on install speed. Do this first and run your full test suite before changing anything else.
  • Step 2 β€” Replace the test runner: Bun's test runner is compatible with Vitest's API for most use cases. Update the test script to bun test and verify all tests pass.
  • Step 3 β€” Replace the runtime: change node to bun in dev scripts. Verify all native dependencies work before this step.
  • Step 4 β€” Add a Node.js matrix job in CI: run tests with node as well as bun to catch compatibility regressions early.
πŸ“Š Production Insight
Bun's speed gains are most significant in large monorepos where install and test times scale with dependency count. On a project with fewer than 50 dependencies, the gains are marginal and may not justify the migration effort. Measure your install and test times before switching β€” if install takes under 15 seconds and tests run under 30 seconds, Bun will not materially change your workflow.
🎯 Key Takeaway
Bun's primary advantage is speed β€” 5-15x faster on cold installs, 2-3x faster on test execution on our monorepo. Ecosystem compatibility is the trade-off. Maintain a Node.js CI fallback and migrate in three stages: package manager, then test runner, then runtime.

Monorepo and Build System: Turborepo with Remote Caching

Turborepo manages the monorepo build graph. The single most important feature is remote caching β€” when a package's inputs have not changed, Turborepo restores its build artifacts from the remote cache instead of rebuilding. This transforms CI from a 12-minute operation to a 2-3 minute operation for typical PRs on our 12-package monorepo.

The monorepo follows a packages-and-apps structure. Shared libraries live in packages/: ui (shadcn/ui components), config (TypeScript, Biome, Tailwind configs), db (Drizzle schema and repositories), validation (shared Zod schemas). Deployable applications live in apps/: web (Next.js), api (Hono background API), and admin (Next.js internal tools).

Task pipelines define dependency relationships. build depends on ^build (build all dependencies first). test depends on build. typecheck depends on ^build. lint and format run independently with no dependencies β€” Turborepo parallelizes them.

The failure mode is cache poisoning via non-deterministic output. If a task produces different output for identical input β€” timestamps embedded in build artifacts, random IDs in generated code, environment variables not listed in env β€” the cache serves the first output forever until manually invalidated. Every cacheable task must produce identical output for identical input.

turbo.json Β· JSON
123456789101112131415161718192021222324252627282930313233343536373839404142434445
{
  "$schema": "https://turbo.build/schema.json",
  "ui": "tui",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"],
      "inputs": [
        "src/**",
        "tsconfig.json",
        "package.json",
        "next.config.ts",
        "tailwind.config.ts"
      ]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"],
      "inputs": [
        "src/**",
        "test/**",
        "vitest.config.ts",
        "bun.test.ts"
      ]
    },
    "typecheck": {
      "dependsOn": ["^build"],
      "outputs": [],
      "inputs": ["src/**", "tsconfig.json"]
    },
    "lint": {
      "dependsOn": [],
      "outputs": [],
      "inputs": ["src/**", "biome.json"]
    },
    "db:generate": {
      "cache": false,
      "outputs": ["drizzle/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}
Mental Model
Why Monorepo Over Polyrepo
A monorepo is not about putting everything in one repository β€” it is about making cross-package changes atomic.
  • Atomic commits: a breaking API change in packages/db and its consumer update in apps/web ship as one commit β€” no coordinated PR dance across repositories
  • Shared tooling: one biome.json, one tsconfig base, one GitHub Actions workflow file for the entire codebase
  • Dependency deduplication: one version of React and Zod, not five slightly different versions across five repositories
  • Discoverability: engineers find shared code without consulting external documentation or knowing which repository owns it
πŸ“Š Production Insight
A monorepo without Turborepo remote caching is slower than separate repositories because CI rebuilds everything every time. Remote caching is not an optimization β€” it is the mechanism that makes the monorepo feasible. Without it, do not use a monorepo. For a team of two on a 12-package monorepo, remote caching reduced average CI time from 12 minutes to 2.5 minutes per PR.
🎯 Key Takeaway
Turborepo's value is entirely in remote caching β€” without it, a monorepo is a CI performance regression. Cache poisoning from non-deterministic output is the primary failure mode. Define precise inputs for every task and verify by running the same task twice and comparing outputs.

Formatting and Linting: Biome

Biome has replaced ESLint + Prettier as the unified formatting and linting tool. Written in Rust, Biome formats and lints a 200-file project in 150-300ms where ESLint + Prettier took 8-12 seconds. In watch mode and pre-commit hooks, this is the difference between feeling instant and feeling sluggish.

Configuration is a single biome.json β€” no plugin conflicts, no version mismatches between eslint-config-* packages, no separate .prettierrc file. When a new engineer joins, they run bun install and Biome works. There is no ESLint plugin resolution step.

Biome's formatter produces output nearly identical to Prettier. The linter covers 90%+ of ESLint rules we actually enforce. The remaining rules come from eslint-plugin-security, which has no Biome equivalent yet β€” we run ESLint in a narrow security-only config alongside Biome for that specific case.

The migration from ESLint + Prettier to Biome is covered by biome migrate, which converts most configurations automatically. The primary friction is custom ESLint plugins β€” evaluate Biome's built-in rule equivalents before deciding which plugins to keep.

biome.json Β· JSON
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
{
  "$schema": "https://biomejs.dev/schemas/latest/schema.json",
  "vcs": {
    "enabled": true,
    "clientKind": "git",
    "useIgnoreFile": true
  },
  "files": {
    "ignoreUnknown": false,
    "ignore": ["node_modules", ".next", "dist", "coverage", "drizzle"]
  },
  "organizeImports": {
    "enabled": true
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "correctness": {
        "noUnusedVariables": "error",
        "noUnusedImports": "error",
        "useExhaustiveDependencies": "error"
      },
      "style": {
        "noNonNullAssertion": "warn",
        "useImportType": "error",
        "noDefaultExport": "warn"
      },
      "suspicious": {
        "noExplicitAny": "error",
        "noConsoleLog": "warn"
      },
      "security": {
        "noDangerouslySetInnerHtml": "error"
      }
    }
  },
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100,
    "lineEnding": "lf"
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "semicolons": "asNeeded",
      "trailingCommas": "all",
      "arrowParentheses": "always"
    }
  }
}
πŸ”₯Biome vs ESLint + Prettier: Honest Trade-off
Biome is faster and simpler but does not support the ESLint plugin ecosystem. The specific gaps in our stack: eslint-plugin-security (no Biome equivalent for several rules) and custom project-specific rules. We run ESLint in a narrow security-only config alongside Biome. If your project depends on eslint-plugin-jsx-a11y, note that Biome's a11y rules cover most of the same ground β€” evaluate the specific rules you actually enforce before deciding to keep ESLint.
πŸ“Š Production Insight
Linting speed affects code quality more than teams realize. When lint takes more than 3 seconds, engineers run it less frequently or disable it in their editor. Biome running in under 300ms means it runs on every save, every commit, and in CI without friction.
🎯 Key Takeaway
Biome replaces ESLint + Prettier with a single Rust-based tool that runs 30-50x faster. The trade-off is plugin ecosystem compatibility. If your project depends on specialized ESLint plugins, audit Biome's built-in rule equivalents before migrating β€” most teams find 90%+ coverage without keeping ESLint.

Terminal Workflow: tmux + Session Scripts

tmux manages all terminal sessions. Every project has a dedicated session with four pre-configured windows: editor, dev server, git operations, and log tailing. Attaching to a session restores the entire context β€” no manual window arrangement, no re-running dev server commands.

Session scripts automate setup. Running tms project-name creates or attaches to a session with the correct layout, starts the dev server, opens lazygit, and tails the relevant log stream. The script is idempotent β€” running it twice attaches to the existing session rather than creating a duplicate.

The key insight is that terminal sessions are persistent work contexts, not disposable windows. Detaching from a project, switching context for three hours, and reattaching restores the session exactly as left β€” dev server running, last test output visible, git diff intact.

For development on remote machines, tmux sessions run on a Fly.io development machine. SSH in from any laptop and attach to the same session. Development environment is machine-independent β€” a broken laptop means attaching from a different machine with no setup time.

~/.local/bin/tms Β· BASH
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
#!/usr/bin/env bash
# tmux session manager
# Usage: tms [project-name]
# If no project name given, uses current directory name
# Idempotent: attaches to existing session if it exists

set -euo pipefail

PROJECT_NAME=${1:-$(basename "$(pwd)")}
SESSION="dev-${PROJECT_NAME}"
PROJECT_DIR=${2:-$(pwd)}

# Check if session already exists
if tmux has-session -t "$SESSION" 2>/dev/null; then
  echo "Attaching to existing session: $SESSION"
  tmux attach-session -t "$SESSION"
  exit 0
fi

echo "Creating new session: $SESSION"

# Window 1: Editor
tmux new-session -d -s "$SESSION" -n editor -c "$PROJECT_DIR"
tmux send-keys -t "$SESSION:editor" 'nvim .' Enter

# Window 2: Dev server
tmux new-window -t "$SESSION" -n server -c "$PROJECT_DIR"
tmux send-keys -t "$SESSION:server" 'bun run dev' Enter

# Window 3: Git (lazygit)
tmux new-window -t "$SESSION" -n git -c "$PROJECT_DIR"
tmux send-keys -t "$SESSION:git" 'lazygit' Enter

# Window 4: Logs β€” tails Fly.io logs for the app matching project name
# Falls back to local docker compose logs if fly cli not available
tmux new-window -t "$SESSION" -n logs -c "$PROJECT_DIR"
if command -v flyctl &>/dev/null; then
  tmux send-keys -t "$SESSION:logs" "flyctl logs --app ${PROJECT_NAME} --tail" Enter
else
  tmux send-keys -t "$SESSION:logs" 'docker compose logs -f --tail=100' Enter
fi

# Window 5: Tests in watch mode
tmux new-window -t "$SESSION" -n tests -c "$PROJECT_DIR"
tmux send-keys -t "$SESSION:tests" 'bun test --watch' Enter

# Return to editor
tmux select-window -t "$SESSION:editor"

tmux attach-session -t "$SESSION"
πŸ’‘tmux Session Design Principles
  • One session per project β€” never mix unrelated work in the same session; context is the value
  • Five standard windows: editor, server, git, logs, tests β€” the tests window running bun test --watch provides continuous feedback without a manual trigger
  • Session scripts must be idempotent β€” running them twice attaches to the existing session, never creates a duplicate
  • Name sessions with a prefix (dev-) to distinguish from ad-hoc terminal sessions created outside the script
πŸ“Š Production Insight
Context switching between projects without tmux costs 5-10 minutes of setup ritual per switch. With session scripts, switching takes 10 seconds β€” run tms other-project in a new terminal and both sessions persist independently. Over a day with three to four context switches, this saves 20-30 minutes.
🎯 Key Takeaway
tmux sessions are persistent work contexts, not disposable terminals. Session scripts eliminate the setup ritual entirely. Your development environment should be one command away β€” tms project-name β€” not four terminals and four commands.

CI/CD Pipeline: GitHub Actions with Turborepo Caching

GitHub Actions runs the CI pipeline. The pipeline is intentionally thin β€” it only runs what cannot run locally. Linting, formatting, and type-checking run in pre-commit hooks locally and are not duplicated in CI. CI runs: unit tests, integration tests, E2E tests (on main branch only), security scan, and deployment.

Turborepo remote caching is the CI performance mechanism. When a PR changes only the web app, CI restores cached build artifacts for every unchanged package β€” the UI library, config packages, and validation schemas rebuild from cache in seconds rather than minutes. A typical PR that touches one app rebuilds one app.

The pipeline has three sequential stages: verify (typecheck, unit tests), integrate (build, integration tests), deploy (preview for PRs, production for main). Each stage gates the next β€” a type error blocks integration tests from running, saving the resources that would be spent on tests that will fail regardless.

Deployment targets: Vercel for the Next.js application, Fly.io for background workers and the Hono API. Both support instant rollback β€” Vercel via deployment history, Fly.io via flyctl releases rollback.

.github/workflows/ci.yml Β· YAML
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ vars.TURBO_TEAM }}
  # Skip env validation in CI β€” secrets are injected directly
  SKIP_ENV_VALIDATION: 'true'

jobs:
  verify:
    name: Typecheck and Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: Type check
        run: bun run typecheck

      - name: Unit tests
        run: bun test --coverage
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}

      - name: Security lint
        run: bunx eslint --config eslint.security.config.js 'src/**/*.{ts,tsx}'

  integrate:
    name: Build and Integration Tests
    needs: verify
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: Build (with Turborepo cache)
        run: bun run build
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}

      - name: Run database migrations on test branch
        run: bun run db:migrate
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}

      - name: Integration tests
        run: bun test --testPathPattern='*.integration.test.ts'
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}

  e2e:
    name: End-to-End Tests
    needs: integrate
    # E2E only runs on main β€” too expensive to run on every PR
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest

      - name: Install dependencies
        run: bun install --frozen-lockfile

      - name: Install Playwright browsers
        run: bunx playwright install --with-deps chromium

      - name: Run E2E tests
        run: bunx playwright test
        env:
          PLAYWRIGHT_BASE_URL: ${{ secrets.STAGING_URL }}

  deploy-preview:
    name: Deploy Preview
    needs: integrate
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Vercel preview
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          scope: ${{ secrets.VERCEL_ORG_ID }}

  deploy-production:
    name: Deploy Production
    needs: [integrate, e2e]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Vercel production
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'
          scope: ${{ secrets.VERCEL_ORG_ID }}
⚠ CI Pipeline Anti-Patterns
πŸ“Š Production Insight
CI that takes more than 5 minutes trains engineers to batch changes and avoid small PRs. Small PRs are easier to review, safer to deploy, and faster to revert. Slow CI works against all of this. For a team of two with 8-10 PRs per week, reducing CI from 12 minutes to 2.5 minutes saved approximately 80 minutes of waiting per week and encouraged smaller, more frequent PRs.
🎯 Key Takeaway
CI should only run what cannot run locally. Pre-commit hooks handle lint, format, and type-check. CI handles tests, security scans, and deployment. Turborepo remote caching makes the monorepo CI fast. Every minute of CI time is paid by every engineer on every PR β€” optimize it ruthlessly.

Database and ORM: Drizzle + Neon

Drizzle ORM manages the database layer. The schema is defined in TypeScript using Drizzle's schema builder β€” there is no separate schema file, no code generation step, and no client to regenerate after schema changes. The TypeScript types derive directly from the schema definition.

Neon provides serverless Postgres. The two properties that matter for this stack: branch databases and instant cold starts. Each pull request can run against a dedicated Neon branch database β€” isolated, disposable, and seeded from a snapshot of production-anonymized data. This eliminated the shared development database that caused flaky integration tests when multiple engineers worked simultaneously.

The repository pattern keeps database logic out of components. All Drizzle queries live in src/db/repositories/. Components call repository functions β€” they never import drizzle or construct queries directly. This is enforced by .cursorrules and by TypeScript's module boundaries.

Migrations are SQL files generated by drizzle-kit and committed to the repository. The migration history is version controlled alongside the schema β€” the database state is always derivable from the repository history.

src/db/schema.ts Β· TYPESCRIPT
123456789101112131415161718192021222324252627282930
import { pgTable, text, timestamp, uuid, integer, boolean, decimal } from 'drizzle-orm/pg-core'

export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: text('email').notNull().unique(),
  name: text('name').notNull(),
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
  updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})

export const subscriptions = pgTable('subscriptions', {
  id: uuid('id').primaryKey().defaultRandom(),
  userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
  planId: text('plan_id').notNull(),
  status: text('status', {
    enum: ['active', 'cancelled', 'past_due', 'trialing'],
  }).notNull(),
  currentPeriodStart: timestamp('current_period_start', { withTimezone: true }).notNull(),
  currentPeriodEnd: timestamp('current_period_end', { withTimezone: true }).notNull(),
  // Financial columns β€” boundary conditions tested manually
  // See: production incident in this article
  cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
  trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }),
})

// Type exports β€” derived from schema, no codegen required
export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsert
export type Subscription = typeof subscriptions.$inferSelect
export type NewSubscription = typeof subscriptions.$inferInsert
⚠ Database Boundary Condition Protocol
πŸ“Š Production Insight
Shared development databases cause flaky integration tests when multiple engineers work simultaneously β€” mutations from one engineer's test run affect another's. Neon branch databases eliminated this entirely. Each engineer runs against their own isolated database branch. CI runs against a fresh branch per job. Flaky integration tests dropped from 15% failure rate to under 1% after the switch.
🎯 Key Takeaway
Drizzle provides type-safe queries without codegen. Neon provides instant-branch Postgres for isolated development and test environments. The repository pattern keeps query logic out of components. Boundary conditions in date ranges require manual test coverage β€” not AI-generated tests.

Deployment and Observability: Vercel, Fly.io, Sentry, PostHog

The deployment layer has four components: Vercel for the Next.js application, Fly.io for background workers and the Hono API, Sentry for error tracking, and PostHog for product analytics and session replay.

Vercel handles Next.js deployment with zero configuration for the standard stack. Edge network deployment, preview URLs for every PR, and instant rollback via deployment history. The main trade-off is cost at scale β€” Vercel's pricing is reasonable for teams but becomes significant at high traffic volumes. Fly.io is the escape valve for workloads that do not fit Vercel's model: long-running jobs, WebSocket servers, background workers.

Sentry captures errors in production and links them to the deployment that introduced them. The integration with Next.js is configured in next.config.ts using @sentry/nextjs. Source maps are uploaded at build time β€” production errors show the original TypeScript source, not the compiled output.

PostHog provides feature flags, event tracking, and session replay. Feature flags allow shipping code to production gated behind a flag β€” a new feature ships to 5% of users, monitored for errors, then rolled out progressively. This reduces deployment risk without requiring separate staging environments for every change.

next.config.ts Β· TYPESCRIPT
12345678910111213141516171819202122232425
import { withSentryConfig } from '@sentry/nextjs'
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    // Turbopack is stable in Next.js 15+ β€” enabled by default with next dev --turbopack
    // ppr: true, // Partial Prerendering β€” evaluate for your use case
  },
  // Enforce that all environment variables are validated at build time
  // via src/env.ts β€” missing variables fail the build, not the runtime
  serverExternalPackages: ['@neondatabase/serverless'],
}

export default withSentryConfig(nextConfig, {
  // Sentry organization and project from environment variables
  org: process.env.SENTRY_ORG,
  project: process.env.SENTRY_PROJECT,
  // Upload source maps to Sentry at build time
  // Allows production errors to show original TypeScript source
  silent: true,
  widenClientFileUpload: true,
  // Hide source maps from client bundle β€” uploaded to Sentry only
  hideSourceMaps: true,
  disableLogger: true,
})
Mental Model
The Observability Minimum
You do not need a full observability platform on day one β€” you need to know when something breaks before your users tell you.
  • Error tracking (Sentry) is mandatory from the first production deployment β€” zero tolerance for silent failures
  • Uptime monitoring (Better Uptime or Vercel's built-in) catches availability issues that Sentry misses
  • Database query monitoring catches performance regressions before they become user-facing slowness
  • Session replay (PostHog) is the fastest way to reproduce UI bugs reported by users who cannot describe what they did
πŸ“Š Production Insight
The most expensive production bugs we have seen are the silent ones β€” no error logs, no exceptions, no alerts. The reconciliation incident in this article was silent for 11 days. Observability at the application level (Sentry errors, PostHog funnel drops) caught a category of issue that infrastructure monitoring missed entirely. Add application-level observability before you need it.
🎯 Key Takeaway
Vercel plus Fly.io covers Next.js applications and background services with instant rollback on both. Sentry is non-negotiable from day one of production. PostHog's feature flags reduce deployment risk without requiring per-feature staging environments. Silent failures are the most expensive β€” instrument the application, not just the infrastructure.

Testing Strategy: The Three-Layer Approach

The testing strategy has three layers that run at different speeds and catch different categories of bugs. The production incident clarified the most important rule: AI generates test structure, humans write assertions for business logic.

Layer 1 β€” Unit tests with Bun test (Vitest-compatible). Colocated with source files as *.test.ts. Run on every save in watch mode and in pre-commit hooks. Fast: the full unit test suite runs in under 10 seconds. Cover: pure functions, utility logic, Zod schema validation, repository functions against a Neon branch. Boundary conditions are written manually β€” date ranges, pagination edges, null/undefined inputs, arithmetic boundaries.

Layer 2 β€” Integration tests with Bun test. Colocated in src/ as *.integration.test.ts. Run in CI after unit tests pass. Slower: 60-90 seconds for the full suite. Cover: Server Actions with real database operations, API route handlers, multi-step user flows that cross module boundaries. Each integration test runs against a fresh Neon branch β€” isolated state, no interference between tests.

Layer 3 β€” E2E tests with Playwright. Located in e2e/. Run in CI on main branch merges only. Slow: 4-8 minutes for the full suite. Cover: one test per critical user journey β€” signup, onboarding completion, subscription upgrade, core product action. E2E tests verify the system works end-to-end; unit and integration tests verify that the individual components work correctly.

src/db/repositories/subscriptions.test.ts Β· TYPESCRIPT
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
import { describe, it, expect, beforeEach } from 'bun:test'
import { getSubscriptionsInPeriod } from './subscriptions'
import { db } from '@/db/client'
import { subscriptions } from '@/db/schema'
import { testDb } from '@/test/helpers/db'

// These tests were written manually after the production incident
// They test the SPECIFICATION (inclusive boundaries) not the implementation
// Do not replace with AI-generated tests

describe('getSubscriptionsInPeriod', () => {
  beforeEach(async () => {
    await testDb.reset()
    await testDb.seed.subscriptions()
  })

  it('includes subscriptions starting exactly on the start date (inclusive boundary)', async () => {
    const startDate = new Date('2026-01-01T00:00:00Z')
    const endDate = new Date('2026-01-31T23:59:59Z')

    // Subscription starting exactly at startDate must be included
    await testDb.insert.subscription({
      currentPeriodStart: startDate, // exactly on boundary
      currentPeriodEnd: new Date('2026-01-31T00:00:00Z'),
      status: 'active',
    })

    const results = await getSubscriptionsInPeriod(startDate, endDate)

    expect(results).toHaveLength(1)
    // Business rule: period start is inclusive β€” subscriptions starting on the
    // exact start date are part of the period
  })

  it('includes subscriptions ending exactly on the end date (inclusive boundary)', async () => {
    const startDate = new Date('2026-01-01T00:00:00Z')
    const endDate = new Date('2026-01-31T23:59:59Z')

    // Subscription ending exactly at endDate must be included
    await testDb.insert.subscription({
      currentPeriodStart: new Date('2026-01-15T00:00:00Z'),
      currentPeriodEnd: endDate, // exactly on boundary
      status: 'active',
    })

    const results = await getSubscriptionsInPeriod(startDate, endDate)

    expect(results).toHaveLength(1)
    // Business rule: period end is inclusive β€” subscriptions ending on the
    // exact end date are part of the period
  })

  it('excludes subscriptions starting after the end date', async () => {
    const startDate = new Date('2026-01-01T00:00:00Z')
    const endDate = new Date('2026-01-31T23:59:59Z')

    await testDb.insert.subscription({
      currentPeriodStart: new Date('2026-02-01T00:00:00Z'), // one day after endDate
      currentPeriodEnd: new Date('2026-02-28T00:00:00Z'),
      status: 'active',
    })

    const results = await getSubscriptionsInPeriod(startDate, endDate)

    expect(results).toHaveLength(0)
  })

  it('returns empty array when no subscriptions exist in period', async () => {
    const startDate = new Date('2025-01-01T00:00:00Z')
    const endDate = new Date('2025-01-31T23:59:59Z')

    const results = await getSubscriptionsInPeriod(startDate, endDate)

    expect(results).toHaveLength(0)
  })
})
⚠ The AI Testing Rule
πŸ“Š Production Insight
AI-generated tests gave us false confidence for three months before the production incident. The tests passed. Reviews passed. The business behavior was wrong. The missing check was independent verification of the specification β€” not the implementation. Manual boundary tests are the only independent check when the same engineer writes both the code and the tests.
🎯 Key Takeaway
Three test layers: unit tests (fast, colocated, every save), integration tests (CI only, real database), E2E tests (main branch only, critical user journeys). AI generates structure; humans write business logic assertions. Boundary conditions are never AI-generated.
πŸ—‚ 2026 Developer Tool Stack Comparison
Trade-offs across alternative tool choices β€” includes why alternatives were evaluated and rejected
CategoryCurrent ChoicePrimary AlternativeWhen to Choose Alternative
EditorNeovim + LazyVimVS Code / ZedTeam uniformity matters more than individual speed. Zed is the closest competitor on performance β€” re-evaluate in 6 months as its plugin ecosystem matures.
AI AssistantCursor + ClaudeGitHub CopilotGitHub Enterprise requirement, single-editor constraint, or lower per-seat budget. Copilot's inline completions are strong β€” the gap is multi-file Agent mode.
RuntimeBunNode.js 22+Native C++ addon dependencies that Bun does not support, or team has existing Node.js tooling deeply integrated in CI.
MonorepoTurborepoNx50+ packages requiring affected-based testing and deep project graph analysis. Nx's code generation is also stronger for larger teams.
Formatter + LinterBiome + ESLint (security only)ESLint + PrettierHeavy dependence on ESLint plugin ecosystem (jsx-a11y, custom rules). Biome does not support plugins β€” evaluate built-in equivalents first.
Terminaltmux + session scriptsWarpPrefer GUI terminal with built-in AI autocomplete and do not have cloud sync restrictions. Warp requires account creation for team features.
CI/CDGitHub ActionsDagger / CircleCINeed local CI execution (Dagger) or complex multi-platform pipeline orchestration (CircleCI). GitHub Actions covers 95% of use cases with less setup.
ORMDrizzlePrismaPrefer generated client and schema introspection over schema-as-code. Prisma's migration workflow is smoother for teams new to database management.
DatabaseNeon (serverless Postgres)Supabase / PlanetScaleNeed Row Level Security and auth integration (Supabase) or horizontal sharding for high-write workloads (PlanetScale). Neon's branch databases are unmatched for development workflows.
Error trackingSentryHighlight.io / DatadogNeed unified infrastructure and APM alongside error tracking (Datadog) or prefer open-source self-hosted option (Highlight.io).

🎯 Key Takeaways

  • Productivity is measured by time-to-merge β€” not lines of code, not tool count, not hours spent. If a tool does not reduce time-to-merge, remove it.
  • AI assistants generate tests that reproduce the same logic errors as the code they test β€” boundary conditions require manual test cases written against the specification, not the implementation.
  • Local-first tools (Biome, Bun, Turborepo remote caching) eliminate CI round-trips for checks that should run in under three seconds before every commit.
  • Neon branch databases eliminate the largest source of flaky integration tests β€” shared mutable state. Each engineer and each CI job gets an isolated database.
  • The .cursorrules file is the highest-leverage configuration in the AI workflow β€” it encodes your architecture, naming conventions, and anti-patterns in a form that Cursor enforces automatically.
  • Silent production failures are the most expensive β€” Sentry error tracking and application-level observability are mandatory from the first production deployment, not after the first incident.
  • Document your stack or it rots β€” STACK.md, bun run setup, and a CONTRIBUTING.md with Git conventions are not optional for any team beyond solo development.

⚠ Common Mistakes to Avoid

    βœ•Adopting tools without measuring time-to-merge before and after
    Symptom

    Team spends more time configuring and maintaining tools than writing code. New engineer onboarding takes three days because the toolchain setup is complex, undocumented, and machine-dependent.

    Fix

    Track time-to-merge as the primary productivity metric. Measure it for two weeks before adopting a new tool and two weeks after. If the tool does not reduce time-to-merge by at least 10%, remove it. Document your stack in a single STACK.md with setup instructions and the reasoning behind each choice.

    βœ•Letting AI generate tests for the same feature it just implemented
    Symptom

    Test suite passes. Production behavior is wrong. Root cause: AI derives test assertions from the implementation rather than the specification, reproducing identical logic errors in both.

    Fix

    Establish the AI testing rule: AI generates test structure (describe blocks, mocks, setup), humans write business logic assertions. Boundary conditions β€” date ranges, numeric limits, inclusive versus exclusive comparisons β€” are always written manually. Document the business rule in a comment above the assertion.

    βœ•Running lint and format checks in CI that already run in pre-commit hooks
    Symptom

    CI takes 8+ minutes including a Biome lint pass that takes 30 seconds in CI but 200ms locally. Engineers wait for CI to tell them about issues they could catch before pushing.

    Fix

    Move all lint, format, and type-check to pre-commit hooks via Husky and lint-staged. CI runs only what cannot run locally: unit tests, integration tests, security scans, deployments. The pre-commit hook is the fast feedback loop; CI is the correctness guarantee.

    βœ•Using a shared development database for integration tests
    Symptom

    Integration tests pass locally but fail in CI intermittently. Root cause: concurrent test runs mutate shared database state. Flaky tests train engineers to ignore failures and merge anyway.

    Fix

    Use Neon branch databases β€” one branch per engineer for development, one fresh branch per CI job for integration tests. Isolated database state eliminates the source of test flakiness. Flaky tests should be treated as bugs, not noise.

    βœ•No environment variable validation at startup
    Symptom

    Application starts without error, then crashes at runtime when a feature that requires a missing environment variable is first accessed. The error occurs in production, not at boot.

    Fix

    Validate all environment variables at startup using Zod and @t3-oss/env-nextjs. Missing or malformed variables throw at build time β€” the deployment fails before traffic is routed to a broken instance. The error is caught in CI, not by users.

    βœ•No single setup command for the development environment
    Symptom

    New engineers take two to three days to get the local environment working. Each engineer's setup is slightly different, causing works-on-my-machine issues in shared tools and scripts.

    Fix

    Create bun run setup that handles everything: bun install, database branch creation, migration run, seed data load, and .env.local generation from .env.example. Test it monthly on a fresh machine or clean container. If it fails, fix it before onboarding the next engineer.

Interview Questions on This Topic

  • QHow do you evaluate whether a new developer tool is worth adopting?Mid-levelReveal
    I measure time-to-merge before and after adoption over a two-week period. That metric captures total throughput β€” how long it takes from starting work on a change to having it deployed. If the tool does not reduce time-to-merge by at least 10%, it is not worth the maintenance and onboarding cost. Beyond the metric, I evaluate four properties: setup complexity (can a new engineer configure it in under 10 minutes?), maintenance burden (does it require frequent config updates or version pinning?), failure mode (does it degrade gracefully or block all development when it breaks?), and team impact (does it save individual time but add team-wide complexity?). The last property is the most commonly missed β€” a tool that makes one engineer 20% faster but adds 30 minutes of onboarding and config debt per new hire is a net negative for a growing team.
  • QWhat is your strategy for using AI coding assistants without creating technical debt?SeniorReveal
    I restrict AI to three safe categories: boilerplate generation (CRUD operations, type definitions, component scaffolding), refactoring assistance (renames across files, extract functions, update import paths), and test structure (describe blocks, mock setup β€” not assertions). I never use AI for architecture decisions, security-sensitive logic, financial calculations, or boundary-condition tests. The boundary-condition rule comes from a specific production incident: AI generated a date-range query with an off-by-one error, then generated tests that reproduced the same error. Both code and tests agreed on the wrong behavior. The tests passed for months before the business impact surfaced. The lesson is that AI-generated tests validate the implementation, not the specification β€” when AI writes both, you have zero independent verification. The rule now: boundary conditions are always written manually, against the business rule, not against the code.
  • QWhy choose Drizzle over Prisma for a production application?Mid-levelReveal
    Three reasons specific to our setup. First, query performance β€” Drizzle's query builder compiles to exactly the SQL you expect. Prisma's query engine adds a translation layer that produced inefficient queries on complex joins in our schema, requiring raw SQL fallbacks that undermined the abstraction. Second, codegen in a monorepo β€” Prisma generates a client that must be committed and kept in sync across packages. In a Turborepo monorepo, this creates cache invalidation complexity and forces all packages that share the database layer to import from the generated client. Drizzle's schema-as-code approach has no generated artifacts to manage. Third, migration files β€” Drizzle generates SQL migration files that are committed to the repository and version-controlled alongside the schema. Prisma's migration workflow in a monorepo with multiple environments required more orchestration. Prisma remains the right choice for teams that prefer schema introspection, a visual studio (Prisma Studio), and a more established migration workflow β€” it is an excellent ORM and its type safety is comparable to Drizzle's.
  • QHow do you design a CI pipeline that engineers do not want to bypass?SeniorReveal
    The pipeline must be fast (under 5 minutes for typical PRs), reliable (no flaky tests), and local-first. Local-first means lint, format, and type-check run in pre-commit hooks before the push β€” engineers learn about issues in two to three seconds, not eight minutes after pushing. CI then runs only what requires infrastructure: unit tests, integration tests, security scans, and deployment. The key design principle is the thin CI model β€” CI is a correctness guarantee, not a development tool. For reliability, the biggest lever is test isolation. Flaky tests come from shared mutable state β€” shared development databases, shared environment variables, shared file system writes. Each CI job gets isolated state: a fresh Neon branch database, no shared fixtures, deterministic seeds. For speed, Turborepo remote caching eliminates rebuilding unchanged packages. For trust, never let engineers merge past a failing gate without explicit override and a documented reason. If engineers regularly need to override, the CI is giving false failures β€” fix the tests, not the policy.
  • QHow do you handle database migrations safely in a continuous deployment workflow?SeniorReveal
    Three practices. First, migrations are committed SQL files generated by drizzle-kit and version-controlled alongside the schema β€” the database state is always derivable from the repository at any point in history. Second, migrations run automatically in CI against a Neon branch before integration tests execute. If the migration fails, the CI job fails before tests run. This catches migration errors in the branch, not in production. Third, all schema changes follow the expand-contract pattern for backward compatibility: add a nullable column before removing the old one, backfill data in a separate migration, then make the column non-nullable in a third migration. This allows a deployment to roll back to the previous version without breaking the database if needed. The worst migration scenario is one that cannot be reversed β€” any migration that drops data requires explicit approval and a backup verification step before it runs in production.

Frequently Asked Questions

Is Neovim worth the learning curve for a team?

Neovim is worth the investment for individual engineers who prioritize terminal integration, startup speed, and long-term composability. For teams, standardize the formatter and linter configuration β€” not the editor. Biome's output is identical regardless of whether the engineer uses Neovim, VS Code, or Zed. Editor choice is personal; consistent code output is a team requirement. If you standardize on Neovim as a team, budget for a two-week ramp period per engineer and maintain a VS Code settings repository as a fallback for engineers who are not productive in Neovim within that window.

How do you handle Bun incompatibility with a dependency?

Three-step resolution. First, check whether the issue is a known Bun bug or an intentional compatibility gap β€” most incompatibilities with modern packages are fixed in Bun releases within weeks. Second, add a Node.js matrix job in GitHub Actions that runs the affected test suite with Node.js alongside the Bun job. This catches regressions without blocking Bun usage elsewhere. Third, if the dependency is critical and the incompatibility is structural (C++ addon that assumes Node.js-specific V8 APIs), keep Node.js as the runtime for that specific package in the monorepo and use Bun for everything else. In our 400-dependency monorepo, fewer than five packages required this treatment after migrating in late 2025.

How do you prevent Turborepo cache from serving stale artifacts?

Define precise inputs for every task in turbo.json. The inputs field determines the cache key β€” if any listed input changes, the cache is invalidated for that task. Include all source files, config files, and environment variable names that affect the task output. Exclude non-deterministic outputs from cacheable tasks: no timestamps, no random IDs, no process IDs in build artifacts. Validate cache correctness by running the same task twice with identical inputs and comparing outputs β€” they must be byte-for-byte identical. If a task cannot produce deterministic output, set cache: false and accept the rebuild cost. For the dev task, cache: false and persistent: true are always correct β€” development servers must not cache.

Can Biome fully replace ESLint for enterprise projects?

Biome covers 90%+ of the ESLint rules that most teams actually enforce. The remaining gaps are in specialized plugins: eslint-plugin-security (no Biome equivalent for several vulnerability detection rules), eslint-plugin-jsx-a11y (Biome's accessibility rules cover most but not all), and custom project-specific rules (Biome does not support custom rule plugins). Our approach: run Biome for all standard formatting and linting, keep ESLint in a narrow security-only config for the rules with no Biome equivalent. This gives the speed benefit of Biome for 95% of checks and keeps the specific ESLint rules that have no alternative. Biome's plugin system is on the roadmap β€” re-evaluate the split configuration annually.

Why use Neon instead of a local Docker Postgres for development?

Three reasons. First, startup time β€” Neon branches are instant, Docker Postgres takes 15-30 seconds to start on first run and requires Docker Desktop running in the background (1.5-2GB memory on macOS). Second, branch isolation β€” each engineer gets a Neon branch derived from a shared snapshot. Multiple engineers can work simultaneously without database state interference. Docker requires manual database resets to isolate state between engineers. Third, CI parity β€” integration tests in CI run against a Neon branch using the same connection pattern as local development. With Docker, CI and local use different database configurations, which is a source of 'passes locally, fails in CI' issues. The trade-off is network latency β€” Neon adds 5-20ms of network overhead per query compared to a local socket connection. For integration tests that run hundreds of queries, this adds seconds. We accept the trade-off for isolation benefits; time-critical code paths have unit tests with mocked database calls.

How do you manage feature flags without a complex feature flag service?

PostHog provides feature flags as part of the analytics SDK β€” no separate service to deploy or maintain. Flags are defined in the PostHog dashboard, evaluated server-side in Server Components using the PostHog Node SDK, and passed down to client components as props. The server-side evaluation ensures flags are resolved before the page renders β€” no layout shift, no loading state for flag values. For flags that need to be evaluated at the edge (middleware, CDN rules), we use a simple JSON config committed to the repository and deployed with the application. Edge flags update on deployment; PostHog flags update in real time. This two-tier approach covers 95% of feature flag use cases without the complexity of a dedicated service.

πŸ”₯
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousBest AI Tools for Developers in 2026 (Curated & Ranked)
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged