Every agency has their "golden stack" — the packages they have battle-tested across enough projects that they install them without thinking. Here is ours, built from shipping 30+ production apps over the past three years.

But here is the thing about package lists: most articles just list names and one-line descriptions. That is useless. What you actually need to know is why a package earned its spot over the alternatives, what tradeoffs it makes, and how to configure it for real-world use. So that is what this post is.

Every package below meets our three criteria: (1) it solves a real problem we hit in every project, (2) it is the best option we have found after trying alternatives, and (3) it has not caused us a production incident. That third one is more selective than you would think. We have removed packages from this list after they broke things — looking at you, old versions of node-fetch.

Package Dependencies Every dependency is a decision. Choose deliberately.

Code Quality and Linting

These are the non-negotiable foundation. Every line of code we ship passes through these tools.

ESLint + @eslint/js (Flat Config)

What it does: Static analysis to catch bugs, enforce conventions, prevent common mistakes. Weekly downloads: ~38 million Why we chose it: There is no real alternative. ESLint is the standard. The question is how you configure it.

We switched to ESLint's flat config format in late 2025 and have not looked back. The old .eslintrc format with its confusing cascade of extends, overrides, and plugins was a source of constant confusion. Flat config is just JavaScript — you export an array of config objects, and what you see is what you get.

Our base eslint.config.js:

import js from "@eslint/js";
import tseslint from "typescript-eslint";

export default [
  js.configs.recommended,
  ...tseslint.configs.strictTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        project: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
    rules: {
      "@typescript-eslint/no-unused-vars": [
        "error",
        { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
      ],
      "@typescript-eslint/no-explicit-any": "error",
      "@typescript-eslint/consistent-type-imports": "error",
      "no-console": ["warn", { allow: ["warn", "error"] }],
      "prefer-const": "error",
      "no-var": "error",
    },
  },
  {
    ignores: ["dist/", "node_modules/", ".next/", "coverage/"],
  },
];

Key decision: strictTypeChecked instead of recommended. Yes, it is more aggressive. Yes, it catches more false positives initially. But it has caught real bugs that recommended would have missed — especially around Promise handling and unsafe type assertions. The week of fixing initial lint errors pays for itself many times over.

What we tried before: We used to maintain a shared config package (@codercops/eslint-config) that extended multiple configs. It was a maintenance burden — every project had slightly different needs and the shared config became a lowest-common-denominator compromise. Individual project configs with a standard starting point work better.

Prettier

What it does: Opinionated code formatter. Handles whitespace, line breaks, semicolons, quotes. Weekly downloads: ~41 million Why we chose it: Eliminates formatting debates permanently.

We use Prettier WITH ESLint, not instead of it. They solve different problems. ESLint catches bugs and enforces patterns. Prettier handles formatting. Running both together means code is both correct and consistent.

Our .prettierrc:

{
  "semi": true,
  "singleQuote": false,
  "tabWidth": 2,
  "trailingComma": "all",
  "printWidth": 100,
  "arrowParens": "always",
  "endOfLine": "lf",
  "plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"]
}

Why double quotes? Most of the JavaScript community uses single quotes. We use double quotes because: (1) JSON uses double quotes, so there is less switching, (2) strings with apostrophes do not need escaping, and (3) it matches more languages (Python, Go, Rust). It is a minor preference, but consistency matters.

The printWidth: 100 choice: Default is 80. We find 80 too narrow for modern widescreen monitors and TypeScript's verbose generics. 100 gives breathing room without lines getting too long to scan.

TypeScript (Strict Mode)

What it does: Static type system for JavaScript. Weekly downloads: ~48 million Why we chose it: Every project starts with TypeScript. No exceptions.

Our tsconfig.json base:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true,
    "exactOptionalPropertyTypes": true,
    "moduleResolution": "bundler",
    "module": "ESNext",
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "esModuleInterop": true
  }
}

The setting most people miss: noUncheckedIndexedAccess. Without it, accessing an array element or object property by index returns the element type. With it, the return type includes | undefined, forcing you to handle the case where the index does not exist. This catches real bugs — accessing array[5] on a 3-element array is a runtime error that TypeScript normally does not catch.

const names = ["Alice", "Bob", "Charlie"];

// Without noUncheckedIndexedAccess:
const name = names[5]; // type: string (but actually undefined at runtime!)

// With noUncheckedIndexedAccess:
const name = names[5]; // type: string | undefined (forces you to check!)

What about exactOptionalPropertyTypes? This prevents assigning undefined to optional properties — you must either provide the value or omit the key entirely. It is strict, but it catches a category of bugs where undefined and "missing" are treated differently by APIs.


Testing

Our testing stack has evolved significantly. Here is where we landed.

Vitest

What it does: Unit and integration test runner. API-compatible with Jest. Weekly downloads: ~12 million Why we chose it: Speed and ESM support.

We migrated from Jest to Vitest in 2024 and have not considered going back. The speed difference is dramatic — our test suite went from 45 seconds to 12 seconds on the same project, with the same tests.

Why Vitest wins:

  • Native ESM support. Jest's ESM support is still experimental and flaky. Vitest handles it natively.
  • Vite-powered. If your project uses Vite (or Astro, which uses Vite), your test environment shares the same config. No duplicate Babel/TypeScript config.
  • In-source testing. You can write tests in the same file as the source code. We do not do this in production code, but it is great for utility functions.
  • Watch mode is instant. Vitest re-runs only affected tests when you save a file. On our largest project (800+ tests), watch mode feedback takes under 500ms.

Our vitest.config.ts:

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,
    environment: "node",
    include: ["src/**/*.test.ts", "src/**/*.spec.ts"],
    coverage: {
      provider: "v8",
      reporter: ["text", "html", "lcov"],
      exclude: ["node_modules/", "dist/", "**/*.d.ts", "**/*.test.ts"],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80,
      },
    },
    setupFiles: ["./tests/setup.ts"],
  },
});

Coverage thresholds: We enforce 80% line coverage as a baseline. Not because 80% is a magic number, but because it forces the team to write tests for core logic without creating busywork testing trivial getters/setters. Projects with lower coverage requirements inevitably drift toward zero testing.

Playwright

What it does: End-to-end browser testing across Chromium, Firefox, and WebKit. Weekly downloads: ~8 million Why we chose it: Reliability, speed, and cross-browser support.

We switched from Cypress to Playwright in mid-2024. The reasons:

1. Cross-browser testing. Cypress only supports Chromium-based browsers and Firefox. Playwright tests Chromium, Firefox, and WebKit (Safari's engine) in parallel. Safari bugs are real and they cost real money to fix in production.

2. Speed. Playwright runs tests in parallel by default and uses browser contexts (lightweight isolated sessions) instead of spinning up new browser instances. Our E2E suite went from 4 minutes (Cypress) to 90 seconds (Playwright).

3. No weird architecture. Cypress runs inside the browser, which creates limitations (no multi-tab testing, no cross-origin testing without workarounds). Playwright controls browsers externally via the DevTools Protocol, which is how browsers are actually meant to be automated.

4. Auto-waiting. Playwright automatically waits for elements to be visible, enabled, and stable before interacting with them. No more cy.wait(1000) or cy.get().should('be.visible') chains.

Example test:

import { test, expect } from "@playwright/test";

test("user can submit contact form", async ({ page }) => {
  await page.goto("/contact");

  await page.fill('[name="name"]', "Test User");
  await page.fill('[name="email"]', "test@example.com");
  await page.fill('[name="message"]', "Hello from Playwright");
  await page.click('button[type="submit"]');

  await expect(page.locator(".success-message")).toContainText(
    "Thanks for reaching out"
  );
});

MSW (Mock Service Worker)

What it does: Intercepts HTTP requests at the network level for testing and development. Weekly downloads: ~4 million Why we chose it: API mocking that actually works like real APIs.

MSW is a game-changer for frontend testing. Instead of mocking fetch or axios directly (which tests your mock, not your code), MSW intercepts actual network requests. Your code makes a real fetch call, MSW catches it, and returns your mock response. The code under test does not know it is being mocked.

import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";

const handlers = [
  http.get("https://api.example.com/users", () => {
    return HttpResponse.json([
      { id: 1, name: "Alice", role: "admin" },
      { id: 2, name: "Bob", role: "user" },
    ]);
  }),

  http.post("https://api.example.com/users", async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: 3, ...body },
      { status: 201 }
    );
  }),

  http.get("https://api.example.com/users/:id", ({ params }) => {
    const { id } = params;
    if (id === "999") {
      return HttpResponse.json(
        { error: "User not found" },
        { status: 404 }
      );
    }
    return HttpResponse.json({ id, name: "Alice" });
  }),
];

const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Why not just mock fetch? Because mocking fetch globally is fragile. If your code switches from fetch to ky or axios, your mocks break. MSW intercepts at the network level, so it works regardless of the HTTP client your code uses. It also works in the browser (for development) using a Service Worker.

Our pattern: We define all mock handlers in a tests/mocks/handlers.ts file and share them between Vitest and Playwright tests. Same mock data, same responses, consistent test behavior.


Utilities

The small packages that save big time.

Zod

What it does: Runtime type validation with TypeScript type inference. Weekly downloads: ~18 million Why we chose it: TypeScript types disappear at runtime. Zod fills the gap.

Zod is the single most impactful utility package in our stack. TypeScript gives you compile-time type safety, but it tells you nothing about data coming from external sources at runtime — API responses, form submissions, environment variables, URL parameters. Zod validates that data and gives you TypeScript types for free.

API response validation:

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(1),
  email: z.string().email(),
  role: z.enum(["admin", "user", "moderator"]),
  createdAt: z.string().datetime(),
});

type User = z.infer<typeof UserSchema>; // TypeScript type, auto-generated

async function getUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  const data = await res.json();
  return UserSchema.parse(data); // throws if data doesn't match schema
}

Environment variable validation:

const EnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(20),
  NODE_ENV: z.enum(["development", "production", "test"]),
  PORT: z.coerce.number().default(3000),
});

export const env = EnvSchema.parse(process.env);
// Now env.DATABASE_URL is typed as string (not string | undefined)
// And you get a clear error at startup if any variable is missing

Form data validation:

const ContactFormSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters"),
  email: z.string().email("Invalid email address"),
  message: z.string().min(10, "Message must be at least 10 characters"),
  budget: z.enum(["under-5k", "5k-15k", "15k-50k", "over-50k"]).optional(),
});

// Use in form handler
const result = ContactFormSchema.safeParse(formData);
if (!result.success) {
  return { errors: result.error.flatten().fieldErrors };
}
// result.data is fully typed and validated

What we tried before: joi, yup, io-ts. Zod wins because of its TypeScript-first design (types are inferred from schemas, not declared separately), smaller bundle size, and cleaner API.

date-fns

What it does: Modular date manipulation — format, parse, compare, add, subtract dates. Weekly downloads: ~24 million Why we chose it: Tree-shakable, immutable, functional API.

import { format, addDays, isAfter, parseISO, formatDistanceToNow } from "date-fns";

// Format a date
format(new Date(), "MMMM d, yyyy"); // "March 23, 2026"

// Add days
const nextWeek = addDays(new Date(), 7);

// Compare dates
isAfter(deadline, new Date()); // true if deadline is in the future

// Parse ISO string
const date = parseISO("2026-03-23T10:30:00Z");

// Relative time
formatDistanceToNow(parseISO("2026-03-20T00:00:00Z")); // "3 days ago"

Why not dayjs? Dayjs is smaller (2KB vs date-fns's tree-shaken size of ~5-8KB per function you use), but date-fns has a critical advantage: every function is a standalone import. Your bundler only includes the functions you actually use. With dayjs, you import the entire library even if you only need format. In practice, date-fns tree-shakes to roughly the same size as dayjs for typical usage.

Why not Moment.js? Moment is 300KB+ and has been in maintenance mode since 2020. It should not be in any new project. If you have Moment in your codebase, migrating to date-fns is straightforward — the API concepts are similar.

Why not Temporal (the native API)? Temporal is still in Stage 3 and not yet available in all runtimes without polyfills. When it ships natively, we will likely adopt it. But today, date-fns is the pragmatic choice.

nanoid

What it does: Generates unique, URL-safe IDs. Weekly downloads: ~15 million Why we chose it: Smaller, faster, and more practical than uuid.

import { nanoid } from "nanoid";

nanoid();     // "V1StGXR8_Z5jdHi6B-myT" (21 chars, 126 bits of entropy)
nanoid(10);   // "IRFa-VaY2b" (shorter, for non-critical IDs)

Why not uuid? uuid generates 36-character strings like 550e8400-e29b-41d4-a716-446655440000. nanoid generates 21-character strings like V1StGXR8_Z5jdHi6B-myT. Both provide sufficient entropy for uniqueness, but nanoid is:

  • 40% smaller (130 bytes vs uuid's 423 bytes)
  • URL-safe by default (no special characters that need encoding)
  • 2x faster in benchmarks
  • Customizable length (use 10 chars for short IDs, 21 for standard, 36 for high-entropy)

We use nanoid for: temporary IDs in UI state, file upload names, short URLs, session tokens, and idempotency keys.


API and Data

ky

What it does: HTTP client built on fetch. Tiny, typed, with retry and hook support. Weekly downloads: ~2 million Why we chose it: Axios is dead weight. fetch alone is too bare. ky is the sweet spot.

import ky from "ky";

// Simple GET
const users = await ky.get("https://api.example.com/users").json();

// POST with JSON body
const newUser = await ky
  .post("https://api.example.com/users", {
    json: { name: "Alice", email: "alice@example.com" },
  })
  .json();

// With retry and timeout
const data = await ky
  .get("https://api.example.com/data", {
    retry: { limit: 3, statusCodes: [408, 429, 500, 502, 503] },
    timeout: 10000,
    hooks: {
      beforeRequest: [
        (request) => {
          request.headers.set("Authorization", `Bearer ${token}`);
        },
      ],
      afterResponse: [
        async (request, options, response) => {
          if (response.status === 401) {
            const newToken = await refreshToken();
            request.headers.set("Authorization", `Bearer ${newToken}`);
            return ky(request);
          }
        },
      ],
    },
  })
  .json();

Why not axios? Axios was built for XMLHttpRequest. It polyfills fetch behavior in a 29KB package. In 2026, every environment we target supports native fetch. ky is 3KB, built on fetch, and does everything we need: JSON parsing, error throwing on non-2xx responses, retry logic, request/response hooks, and timeout handling.

The migration from axios to ky is straightforward:

// Axios
const { data } = await axios.get("/api/users");

// ky
const data = await ky.get("/api/users").json();

Almost identical API, 90% less bundle weight.

superjson

What it does: Serializes JavaScript values that JSON cannot handle — Dates, Maps, Sets, BigInts, Infinity, RegExp. Weekly downloads: ~3 million Why we chose it: JSON.stringify cannot handle Dates. This solves it cleanly.

The problem: you fetch a user from your database. The createdAt field is a Date object. You serialize it to JSON to send to the frontend. JSON.stringify turns it into a string. On the frontend, JSON.parse gives you a string, not a Date. Now you need to remember to call new Date() on it. Every time. For every date field. In every API response.

superjson handles this transparently:

import superjson from "superjson";

const data = {
  user: {
    name: "Alice",
    createdAt: new Date("2026-01-15"),
    permissions: new Set(["read", "write"]),
    metadata: new Map([["theme", "dark"]]),
  },
};

// Serialize (server-side)
const serialized = superjson.stringify(data);

// Deserialize (client-side) — Date, Set, and Map are restored
const restored = superjson.parse(serialized);
restored.user.createdAt instanceof Date; // true
restored.user.permissions instanceof Set; // true
restored.user.metadata instanceof Map; // true

We use superjson in every project that passes data between server and client. It is especially useful with Next.js Server Components, tRPC, and any API that returns complex types.


Build and Dev

dotenv (When Framework-Native Is Not Available)

What it does: Loads environment variables from .env files into process.env. Weekly downloads: ~35 million Why we still use it: Not every context has framework-native env handling.

Important caveat: Most modern frameworks (Next.js, Astro, Vite, Remix) have built-in .env file support. If you are using one of these, you do NOT need dotenv. We use it only for:

  • Standalone Node.js scripts (database migrations, seed scripts, cron jobs)
  • Older projects that predate framework-native env handling
  • Testing environments where Vitest does not auto-load .env
// Only in scripts and non-framework contexts
import "dotenv/config";

// Then validate with zod (see above)
const env = EnvSchema.parse(process.env);

Rule of thumb: If your framework's docs mention .env file support, use that instead of dotenv.

concurrently

What it does: Runs multiple commands in parallel with colored, labeled output. Weekly downloads: ~8 million Why we chose it: Our dev command runs 3 processes simultaneously.

{
  "scripts": {
    "dev": "concurrently -k -n 'WEB,API,TYPES' -c 'cyan,green,yellow' 'astro dev' 'node scripts/api-server.js' 'tsc --watch --noEmit'",
    "dev:full": "concurrently -k -n 'WEB,DB,MAIL' -c 'cyan,green,magenta' 'npm run dev' 'supabase start' 'npm run email:dev'"
  }
}

The -k flag kills all processes when one exits (so if the web server crashes, the API server stops too). The -n flag gives each process a labeled prefix. The -c flag assigns colors so you can visually distinguish output.

What we tried before: Running multiple terminal tabs manually. It works, but you forget to start one of the three servers, spend 10 minutes debugging "why is nothing working," and then realize the API server was not running.

husky + lint-staged

What it does: Runs linting and formatting on staged files before every commit. Weekly downloads: husky ~10 million, lint-staged ~9 million Why we chose it: Catches issues before they enter the repository.

Setup:

npm install -D husky lint-staged
npx husky init

.husky/pre-commit:

npx lint-staged

package.json (or .lintstagedrc.json):

{
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": [
      "eslint --fix --max-warnings=0",
      "prettier --write"
    ],
    "*.{json,md,mdx,css}": [
      "prettier --write"
    ],
    "*.ts": [
      "bash -c 'tsc --noEmit'"
    ]
  }
}

Key detail: --max-warnings=0. This means ESLint warnings are treated as errors in the pre-commit hook. Without this, warnings accumulate silently until you have 200 of them and they become meaningless noise.

Why lint-staged instead of linting the entire project? Because linting every file on every commit takes 30+ seconds on a large project. lint-staged only lints the files you changed, so pre-commit hooks finish in 2-3 seconds. Fast hooks mean developers do not bypass them with --no-verify.


Packages We Explicitly Avoid

This section is as important as the list above. Here are packages we do not install and why.

lodash

Why we skip it: Modern JavaScript covers 95% of lodash's use cases natively.

// lodash: _.get(obj, 'a.b.c', defaultValue)
// Native: obj?.a?.b?.c ?? defaultValue

// lodash: _.uniq(array)
// Native: [...new Set(array)]

// lodash: _.groupBy(array, 'category')
// Native: Object.groupBy(array, item => item.category)

// lodash: _.debounce(fn, 300)
// Native: Use a 3-line utility function

lodash adds 70KB (full) or 5-10KB (cherry-picked) to your bundle. For the few functions that do not have native equivalents, write a 10-line utility. We have a src/utils/ folder with debounce, throttle, and deepMerge — that covers everything we need.

Moment.js

Why we skip it: Deprecated since 2020. 300KB+ bundle size. Mutable API that causes bugs. Use date-fns instead. There is no reason to start a new project with Moment in 2026.

axios

Why we skip it: 29KB for an HTTP client when fetch is native everywhere. ky (3KB) provides the same convenience (automatic JSON parsing, error throwing, interceptors) on top of native fetch. Axios was essential in 2018 when browsers did not have fetch. That era is over.

Express

Why we skip it: For new API projects, we use Hono or Fastify instead.

  • Hono: 14KB, runs everywhere (Node, Deno, Bun, Cloudflare Workers, AWS Lambda). Web-standards-based. Middleware ecosystem is growing fast. TypeScript-first.
  • Fastify: More mature, plugin ecosystem, JSON schema validation built in. Better for traditional Node.js servers.

Express is not bad — it just has not evolved. No native TypeScript support, no async error handling, and its middleware pattern shows its age. For maintaining existing Express apps, it is fine. For new projects, there are better choices.

class-validator / class-transformer

Why we skip them: Decorator-based validation requires experimentalDecorators and reflects a pre-TypeScript-4.0 approach. Zod does everything these packages do, with a functional API, better TypeScript inference, and no decorator magic.


Our Starter package.json

Here is the package.json we start every new project with (framework-specific dependencies excluded):

{
  "name": "project-name",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "concurrently -k -n 'WEB,TYPES' -c 'cyan,yellow' 'framework dev command' 'tsc --watch --noEmit'",
    "build": "tsc --noEmit && framework build command",
    "test": "vitest",
    "test:e2e": "playwright test",
    "test:coverage": "vitest --coverage",
    "lint": "eslint . --max-warnings=0",
    "lint:fix": "eslint . --fix --max-warnings=0",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "typecheck": "tsc --noEmit",
    "validate": "npm run typecheck && npm run lint && npm run test -- --run",
    "prepare": "husky"
  },
  "devDependencies": {
    "@eslint/js": "^9.x",
    "@playwright/test": "^1.x",
    "concurrently": "^9.x",
    "eslint": "^9.x",
    "husky": "^9.x",
    "lint-staged": "^15.x",
    "msw": "^2.x",
    "prettier": "^3.x",
    "typescript": "^5.x",
    "typescript-eslint": "^8.x",
    "vitest": "^3.x"
  },
  "dependencies": {
    "date-fns": "^4.x",
    "ky": "^1.x",
    "nanoid": "^5.x",
    "superjson": "^2.x",
    "zod": "^3.x"
  },
  "lint-staged": {
    "*.{ts,tsx,js,jsx}": [
      "eslint --fix --max-warnings=0",
      "prettier --write"
    ],
    "*.{json,md,mdx,css}": [
      "prettier --write"
    ]
  }
}

That is 14 packages total. Five runtime dependencies, nine dev dependencies. Each one earns its spot by solving a specific problem better than the alternatives.

How We Evaluate New Packages

Before adding any package to a project, it goes through our evaluation checklist:

  1. Can we solve this with native JavaScript/TypeScript? If yes, do that instead.
  2. Is it actively maintained? Check the last commit date, open issues, and release frequency.
  3. What is the bundle size? Check on bundlephobia.com. If it is over 20KB, the benefit needs to be significant.
  4. How many dependencies does it have? Fewer is better. Every transitive dependency is a supply chain risk.
  5. Is it tree-shakable? For frontend packages, this is non-negotiable. We should only ship the code we use.
  6. Has it had security vulnerabilities? Check on Snyk or npm audit.
  7. Can we rip it out easily? Packages that spread across your entire codebase (ORMs, state management libraries) are much riskier than utilities you can swap in a day.

This checklist has saved us from adopting packages that looked promising but would have caused problems. The best dependency is the one you do not add.


Build Your Tech Stack With Us

At CODERCOPS, we help teams make smart technology decisions — from package selection to architecture design. Our stack choices come from years of production experience, not hype cycles.

If you are starting a new project or evaluating your current dependencies, reach out for a free consultation. Or explore our blog for more practical guides on web development tools and practices.

Comments