My 2026 Developer Productivity Stack (Tools, Workflow & Hard Lessons)
- 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.
- 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 Incident
Production Debug GuideWhen your tools are slowing you down instead of speeding you up
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.
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 = '_' }, }, }, }, }
- 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
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.
# 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
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.
{
"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"
}
}
- 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.
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.
{
"$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
}
}
}
- 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
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.
{
"$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"
}
}
}
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.
#!/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"
- 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
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.
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 }}
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.
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
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.
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, })
- 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
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.
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) }) })
| Category | Current Choice | Primary Alternative | When to Choose Alternative |
|---|---|---|---|
| Editor | Neovim + LazyVim | VS Code / Zed | Team uniformity matters more than individual speed. Zed is the closest competitor on performance β re-evaluate in 6 months as its plugin ecosystem matures. |
| AI Assistant | Cursor + Claude | GitHub Copilot | GitHub Enterprise requirement, single-editor constraint, or lower per-seat budget. Copilot's inline completions are strong β the gap is multi-file Agent mode. |
| Runtime | Bun | Node.js 22+ | Native C++ addon dependencies that Bun does not support, or team has existing Node.js tooling deeply integrated in CI. |
| Monorepo | Turborepo | Nx | 50+ packages requiring affected-based testing and deep project graph analysis. Nx's code generation is also stronger for larger teams. |
| Formatter + Linter | Biome + ESLint (security only) | ESLint + Prettier | Heavy dependence on ESLint plugin ecosystem (jsx-a11y, custom rules). Biome does not support plugins β evaluate built-in equivalents first. |
| Terminal | tmux + session scripts | Warp | Prefer GUI terminal with built-in AI autocomplete and do not have cloud sync restrictions. Warp requires account creation for team features. |
| CI/CD | GitHub Actions | Dagger / CircleCI | Need local CI execution (Dagger) or complex multi-platform pipeline orchestration (CircleCI). GitHub Actions covers 95% of use cases with less setup. |
| ORM | Drizzle | Prisma | Prefer generated client and schema introspection over schema-as-code. Prisma's migration workflow is smoother for teams new to database management. |
| Database | Neon (serverless Postgres) | Supabase / PlanetScale | Need 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 tracking | Sentry | Highlight.io / Datadog | Need 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
Interview Questions on This Topic
- QHow do you evaluate whether a new developer tool is worth adopting?Mid-levelReveal
- QWhat is your strategy for using AI coding assistants without creating technical debt?SeniorReveal
- QWhy choose Drizzle over Prisma for a production application?Mid-levelReveal
- QHow do you design a CI pipeline that engineers do not want to bypass?SeniorReveal
- QHow do you handle database migrations safely in a continuous deployment workflow?SeniorReveal
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.
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.