Web Development · Developer Tools
npm Packages for Every New TypeScript Project in 2026
A 16-package starter set for TypeScript projects: ESLint, Prettier, Vitest, Playwright, MSW, Zod, ky and more, with current versions and the packages to skip.
Anurag Verma
17 min read · Updated Oct 19, 2026
Sponsored
Most package lists give you names and a one-line description each. That doesn’t help you decide. What you need is why a package beats the alternatives, what it costs you, and how to configure it so it’s useful on day one. That’s what this post covers, along with the packages worth leaving out.
Versions and APIs below were checked against npm and each project’s release notes on October 19, 2026. A lot changed this year: ESLint, Zod, ky, Vitest, MSW, nanoid, lint-staged and concurrently all shipped new majors, and several now require Node 22. If your project is still on Node 20, upgrade that first.
Every dependency is a decision. Choose deliberately.
Which npm packages should every new project start with?
Start with five runtime packages (zod, ky, date-fns, nanoid, superjson) and eleven dev tools: ESLint with typescript-eslint, Prettier, TypeScript, Vitest, Playwright, MSW, husky, lint-staged and concurrently. Run them on Node 22.12 or newer, pin TypeScript to 6.x for linting compatibility, and add framework packages on top.
The rest of this post explains each choice and shows a working config.
Code quality and linting
Three tools: ESLint for bugs and conventions, Prettier for formatting, and TypeScript in strict mode for types. They do different jobs and work well together.
ESLint 10 with typescript-eslint
ESLint is the standard JavaScript linter, and the question is how you configure it. As of ESLint 10, flat config is the only format: “Starting with ESLint v10.0.0, the old configuration format is no longer supported” (ESLint migration guide). .eslintrc files are gone, and ESLint 10 needs Node ^20.19, ^22.13 or >=24. If you’re still migrating, our ESLint flat config migration guide walks through the conversion.
A base eslint.config.js using the current recommended shape:
import { defineConfig } from "eslint/config";
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default defineConfig([
{
ignores: ["dist/", "node_modules/", ".next/", "coverage/"],
},
{
files: ["**/*.{ts,tsx}"],
extends: [js.configs.recommended, tseslint.configs.strictTypeChecked],
languageOptions: {
parserOptions: {
projectService: true,
},
},
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"] }],
},
},
]);
Two things changed from older examples. defineConfig from eslint/config replaces typescript-eslint’s tseslint.config() helper, which is now deprecated in favor of it (typescript-eslint). And typed linting uses projectService: true instead of project: true (typed linting docs), which finds the right tsconfig for each file without extra setup.
Why strictTypeChecked instead of recommendedTypeChecked? It is more aggressive and you will fix a batch of errors when you first turn it on. In return it flags floating promises, misused promises and unsafe any flows that the basic config lets through. On a new project, starting strict is far cheaper than tightening later.
Prettier
Prettier formats code so nobody argues about whitespace in review. Use it alongside ESLint, not instead of it: ESLint catches bugs, Prettier handles layout. Biome combines both and is much faster, and is worth evaluating; the ESLint plugin ecosystem is still the reason most projects stay on ESLint plus Prettier.
A starting .prettierrc:
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 100,
"arrowParens": "always",
"endOfLine": "lf",
"plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"]
}
Double quotes and printWidth: 100 are preferences, not rules. Double quotes match JSON and avoid escaping apostrophes; 100 columns leaves room for TypeScript generics. Pick something and let the tool enforce it. Drop the plugins if you don’t use Astro or Tailwind.
TypeScript 6 in strict mode
Here is the one place where “install the latest” is wrong. npm’s latest TypeScript is 7.0, the native compiler, and Microsoft’s announcement says that “while TypeScript 7.0 is here, it does not ship with an API” (TypeScript 7.0). typescript-eslint imports that API, and its peer range is TypeScript >=4.8.4 <6.1.0 (dependency versions). So keep the typescript package on 6.x. The TypeScript 7 post shows an npm alias setup if you want the faster compiler for type checking alongside 6.x for tools, and our TypeScript 7 migration playbook covers the move in detail.
A base tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"exactOptionalPropertyTypes": true,
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true
}
}
TypeScript 6.0 changed several defaults (TypeScript 6.0). strict now defaults to true (it stays here for clarity). esModuleInterop can no longer be set to false, so the line is redundant and gone. DOM.Iterable is now folded into DOM. And types defaults to an empty array, which means @types/node and vitest/globals are no longer picked up automatically; list what you need in types.
The option most people miss is noUncheckedIndexedAccess. Without it, indexing an array returns the element type even when the index is out of range. With it, the type includes undefined and you have to handle that case:
const names = ["Alice", "Bob", "Charlie"];
// Without noUncheckedIndexedAccess:
const a = names[5]; // type: string (actually undefined at runtime)
// With noUncheckedIndexedAccess:
const b = names[5]; // type: string | undefined (forces a check)
exactOptionalPropertyTypes is similar in spirit. It stops you assigning undefined to an optional property, so “missing” and “explicitly undefined” stay distinct, which matters for APIs that treat them differently.
Testing
Vitest
Vitest is a unit and integration test runner with a Jest-compatible API. The reasons to choose it over Jest are native ES module and TypeScript support, and shared configuration with Vite-based frameworks like Astro. Watch mode reruns only tests affected by a change. Our guide to replacing Jest with Vitest covers the migration path.
Vitest 5 requires Node 22.12 or newer and Vite 6.4 or newer, with vite now a peer dependency (Vitest migration guide). Vitest 4 removed coverage.all and now only reports files your tests import unless you set coverage.include (Vitest 4 migration). The default excludes also shrank to node_modules and .git, so exclude test files yourself.
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
include: ["src/**/*.test.ts", "src/**/*.spec.ts"],
setupFiles: ["./tests/setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
include: ["src"],
exclude: ["**/*.d.ts", "**/*.test.ts", "**/*.spec.ts"],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
},
},
});
If you use globals: true, add "vitest/globals" to types in your tsconfig. Also note that Vitest 5 turns on clearMocks by default.
Coverage thresholds are a judgment call. Something around 80% lines forces tests for core logic without pushing people to test trivial getters. Set it, then adjust once you see where the gaps are.
Playwright
Playwright runs end-to-end tests in Chromium, Firefox and WebKit (Safari’s engine), in parallel by default, with isolated browser contexts per test. It drives browsers from outside the page, which keeps multi-tab and cross-origin flows simple, and it auto-waits for elements to be actionable before interacting. Our Playwright E2E testing guide covers a full setup.
Use locators, not the older page methods. The Playwright docs now mark page.click() and page.fill() as discouraged in favor of locator-based calls (Playwright API):
import { test, expect } from "@playwright/test";
test("user can submit contact form", async ({ page }) => {
await page.goto("/contact");
await page.getByLabel("Name").fill("Test User");
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Message").fill("Hello from Playwright");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText("Thanks for reaching out")).toBeVisible();
});
Locators by label and role also double as a light accessibility check: if the test can’t find the field by its label, neither can a screen reader.
MSW (Mock Service Worker)
MSW intercepts HTTP requests at the network level. Your code makes a real fetch call, MSW catches it and returns your mock, and the code under test doesn’t know it’s being mocked. Mocking fetch or an HTTP client directly tests your mock; MSW tests your code. It also works in the browser through a Service Worker, so the same handlers can back local development.
MSW 3 is ESM-only, requires Node 22 or newer, and the docs now import handlers from msw/http (MSW 3 migration). onUnhandledRequest was renamed to onUnhandledFrame.
import { http, HttpResponse } from "msw/http";
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()) as Record<string, unknown>;
return HttpResponse.json({ id: 3, ...body }, { status: 201 });
}),
http.get("https://api.example.com/users/:id", ({ params }) => {
if (params.id === "999") {
return HttpResponse.json({ error: "User not found" }, { status: 404 });
}
return HttpResponse.json({ id: params.id, name: "Alice" });
}),
];
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Keep handlers in one file, such as tests/mocks/handlers.ts, and share them between unit tests and local development, so mock data stays consistent everywhere.
Utilities
Zod
TypeScript types disappear at runtime, so they tell you nothing about API responses, form submissions, environment variables or URL parameters. Zod validates that data and infers TypeScript types from the same schema. For a small dependency, it removes a whole class of bugs. More patterns are in our Zod production guide.
Zod 4 changed the preferred API. The method forms like z.string().email() “still exist and work as before, but are now deprecated” in favor of top-level z.email(), z.url() and z.iso.datetime(), and error.flatten() is deprecated too (Zod 4 changelog).
API response validation:
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1),
email: z.email(),
role: z.enum(["admin", "user", "moderator"]),
createdAt: z.iso.datetime(),
});
type User = z.infer<typeof UserSchema>;
async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return UserSchema.parse(await res.json()); // throws if the shape is wrong
}
Environment variable validation:
const EnvSchema = z.object({
DATABASE_URL: z.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);
// env.DATABASE_URL is typed as string, and a missing variable fails at startup
Form validation:
const ContactFormSchema = z.object({
name: z.string().min(2, { error: "Name must be at least 2 characters" }),
email: z.email({ error: "Invalid email address" }),
message: z.string().min(10, { error: "Message must be at least 10 characters" }),
budget: z.enum(["under-5k", "5k-15k", "15k-50k", "over-50k"]).optional(),
});
const result = ContactFormSchema.safeParse(formData);
if (!result.success) {
return { errors: z.flattenError(result.error).fieldErrors };
}
// result.data is typed and validated
Alternatives like joi, yup and io-ts work, but Zod’s TypeScript-first design (types inferred from schemas rather than declared twice) is the reason it became the default.
date-fns
date-fns is a modular, immutable date library where every function is its own import, so bundlers include only what you use.
import { format, addDays, isAfter, parseISO, formatDistanceToNow } from "date-fns";
format(new Date(), "MMMM d, yyyy"); // "October 19, 2026"
const nextWeek = addDays(new Date(), 7);
isAfter(deadline, new Date()); // true if the deadline is in the future
const date = parseISO("2026-10-19T10:30:00Z");
formatDistanceToNow(parseISO("2026-10-16T00:00:00Z")); // "3 days ago"
Day.js is the main alternative. It is smaller as a whole, but uses a chainable object and plugins; date-fns’s per-function imports tree-shake well, so the difference in a real bundle is often small. Pick one and stay consistent.
What about Temporal? The proposal reached Stage 4 and ships in Firefox 139, Chrome 144 and Node 26, but not yet in Safari (TC39 proposal). On a server running Node 26 you can use it today. In browser code that has to work in Safari, date-fns (or a Temporal polyfill) is still the pragmatic choice.
nanoid
nanoid generates short, URL-safe random IDs: 21 characters from a 64-symbol alphabet by default, which gives about 126 random bits.
import { nanoid } from "nanoid";
nanoid(); // "V1StGXR8_Z5jdHi6B-myT"
nanoid(10); // "IRFa-VaY2b" (shorter, for low-stakes IDs)
It suits short URLs, upload file names, idempotency keys and client-side IDs where a 36-character UUID is clumsy. If you specifically need a standard UUID, you don’t need a package: crypto.randomUUID() is built into Node and modern browsers. nanoid 6 dropped Node 18 and 20 support but kept the same API (nanoid changelog).
API and data
ky
ky is a small HTTP client built on fetch. It adds what bare fetch lacks: JSON helpers, errors on non-2xx responses, retries with backoff, hooks and timeouts. ky 2 requires Node 22 and changed every hook to take a single state object (ky 2.0 release notes). It also renamed prefixUrl to prefix, and .json() now throws on empty bodies and 204 responses.
import ky from "ky";
const users = await ky.get("https://api.example.com/users").json();
const newUser = await ky
.post("https://api.example.com/users", {
json: { name: "Alice", email: "alice@example.com" },
})
.json();
const data = await ky
.get("https://api.example.com/data", {
retry: { limit: 3, statusCodes: [408, 429, 500, 502, 503] },
timeout: 10_000,
hooks: {
beforeRequest: [
({ request }) => {
request.headers.set("Authorization", `Bearer ${getToken()}`);
},
],
afterResponse: [
async ({ request, response, retryCount }) => {
if (response.status === 401 && retryCount === 0) {
const token = await refreshToken();
const headers = new Headers(request.headers);
headers.set("Authorization", `Bearer ${token}`);
return ky.retry({ request: new Request(request, { headers }), code: "TOKEN_REFRESHED" });
}
},
],
},
})
.json();
Why not axios? It is actively maintained and fine to keep in existing code. For new code, every runtime you are likely to target has native fetch, and ky gives you the same conveniences as a much thinner layer over it. Moving simple calls is close to mechanical:
// axios
const { data } = await axios.get("/api/users");
// ky
const data = await ky.get("/api/users").json();
superjson
JSON.stringify turns a Date into a string, and JSON.parse leaves it a string. The same goes for Map, Set, BigInt and friends. superjson serializes those types with enough metadata to restore them on the other side:
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"]]),
},
};
const serialized = superjson.stringify(data); // on the server
const restored = superjson.parse<typeof data>(serialized); // on the client
restored.user.createdAt instanceof Date; // true
restored.user.permissions instanceof Set; // true
restored.user.metadata instanceof Map; // true
It’s most useful with tRPC and anywhere you pass rich types between server and client. If your API only returns plain JSON, skip it.
Build and dev
Environment files: Node first, dotenv if needed
Most frameworks (Next.js, Astro, Vite, Remix) load .env files for you. For standalone scripts, Node now does too: --env-file was added in Node 20.6 and process.loadEnvFile() in 20.12, and both are no longer experimental in Node 22.21 and 24.10 (Node docs).
node --env-file=.env scripts/seed.js
If you still want dotenv, import "dotenv/config" works in dotenv 18 and is quiet by default; calling config() directly logs unless you pass { quiet: true } (dotenv changelog). Either way, validate the result with the Zod schema above.
concurrently
concurrently runs several commands in one terminal with labeled, colored output, so you don’t forget to start the API server and spend ten minutes wondering why nothing works.
{
"scripts": {
"dev": "concurrently -k -n WEB,API,TYPES -c cyan,green,yellow \"astro dev\" \"node scripts/api-server.js\" \"tsc --watch --noEmit\""
}
}
-k kills the other processes when one exits, -n names each one, and -c sets colors. concurrently 10 kept these flags, requires Node 22, and is ESM-only (concurrently 10).
husky and lint-staged
These run linting and formatting on staged files before each commit.
npm install -D husky lint-staged
npx husky init
.husky/pre-commit:
npx lint-staged
In package.json (or .lintstagedrc.json):
{
"lint-staged": {
"*.{ts,tsx,js,jsx}": ["eslint --fix --max-warnings=0", "prettier --write"],
"*.{json,md,mdx,css}": ["prettier --write"]
}
}
--max-warnings=0 treats warnings as errors in the hook, so warnings can’t pile up until nobody reads them. lint-staged only checks changed files, which keeps the hook fast enough that people don’t bypass it with --no-verify. Run the full tsc --noEmit in CI rather than in the hook; type checking is project-wide and slow to run per commit. lint-staged 16 removed the --shell option and 17 requires Node 22.22.1 and Git 2.32 or newer (lint-staged changelog).
Packages to skip in new projects
lodash
Modern JavaScript covers most of what lodash was for:
// _.get(obj, "a.b.c", fallback)
obj?.a?.b?.c ?? fallback;
// _.uniq(array)
[...new Set(array)];
// _.groupBy(array, "category")
Object.groupBy(array, (item) => item.category);
Object.groupBy is Baseline widely available, “across browsers since March 2024” (MDN). For the few helpers without a native equivalent (debounce, throttle, a deep merge), a short local utility or a single-purpose package is usually enough.
Moment.js
Moment’s own docs say it “is a legacy project in maintenance mode” (Moment.js docs). Its API is mutable, which causes subtle bugs, and it is large compared with modern alternatives. Use date-fns or Temporal in new code.
class-validator and class-transformer
Decorator-based validation ties you to classes and decorator configuration. Zod does the same job with plain functions and better type inference.
Express, reconsidered
Express used to be on this list because it didn’t handle async errors. Express 5 fixed that: rejected promises from handlers and middleware are now forwarded to error handling “as if calling next(err)” (Express 5 migration guide). It’s a reasonable choice again, especially for teams that know it. For new TypeScript-first or edge-deployed APIs, Hono (web-standards based, runs on Node, Deno, Bun and Cloudflare Workers) and Fastify (mature plugin system, built-in JSON schema validation) are still better fits.
The starter package.json
Framework-specific packages excluded:
{
"name": "project-name",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.22.1"
},
"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": "^10.0.0",
"@playwright/test": "^1.64.0",
"concurrently": "^10.0.0",
"eslint": "^10.0.0",
"husky": "^9.1.0",
"lint-staged": "^17.0.0",
"msw": "^3.0.0",
"prettier": "^3.9.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.71.0",
"vitest": "^5.0.0"
},
"dependencies": {
"date-fns": "^4.4.0",
"ky": "^2.2.0",
"nanoid": "^6.0.0",
"superjson": "^2.2.0",
"zod": "^4.6.0"
}
}
The engines floor comes from lint-staged 17, the strictest requirement in the set. TypeScript uses ~6.0.3 so a future 6.1 doesn’t land before typescript-eslint supports it. Add @types/node if you write Node code, and vite explicitly if your package manager doesn’t install peer dependencies automatically.
How to evaluate a new package
Before adding anything else, run through these questions:
- Can native JavaScript or the platform do this? If so, use that.
- Is it actively maintained? Check recent releases, open issues and how fast security reports get fixed.
- How big is it? Check it on bundlephobia.com for frontend code. A large package needs a large benefit.
- How many dependencies does it pull in? Each transitive dependency is supply chain risk; our npm supply chain hardening checklist covers how to limit it.
- Does it tree-shake? For frontend packages, you should only ship what you use.
- What is its security history? Check
npm auditand an advisory database such as Snyk. - How hard is it to remove? Utilities you can swap in a day are low risk. Packages that spread through the whole codebase, like ORMs and state libraries, deserve much more scrutiny.
The cheapest dependency is the one you don’t add.
Frequently asked questions
- What should go in package.json for a new TypeScript project?
- A good starting point is five runtime dependencies (zod, ky, date-fns, nanoid, superjson) and eleven dev dependencies (eslint, @eslint/js, typescript-eslint, prettier, typescript, vitest, @playwright/test, msw, husky, lint-staged, concurrently), before any framework packages. Drop any you don't need; ky and superjson in particular only earn their place in apps that make many HTTP calls or pass rich types between server and client.
- Should I install TypeScript 7?
- Not as your only TypeScript package yet. TypeScript 7.0 is the native compiler and ships without the JavaScript API that tools like typescript-eslint import, and typescript-eslint 8 declares support for TypeScript below 6.1. Keep typescript on 6.x for tooling; Microsoft's TypeScript 7 announcement shows an npm alias setup if you want the faster compiler alongside it.
- Why use ky instead of axios?
- Every modern runtime has native fetch, and ky is a small wrapper over it that adds JSON helpers, retries with backoff, hooks, timeouts and errors on non-2xx responses. Axios still works and is maintained, but it is a larger dependency built around its own adapter layer. ky 2 requires Node 22 and changed its hook signature to a single state object.
- Why Vitest instead of Jest?
- Vitest supports ES modules and TypeScript natively, shares configuration with Vite-based frameworks such as Astro, and keeps a Jest-compatible API. Vitest 5 requires Node 22.12 or newer and Vite 6.4 or newer, and since Vitest 4 you should set coverage.include explicitly.
- Do I still need lodash or Moment.js?
- Usually not. Optional chaining, Set and Object.groupBy (Baseline widely available since March 2024) cover most lodash use, and Moment.js describes itself as a legacy project in maintenance mode. Use date-fns today, and the Temporal API where your runtimes support it (Chrome 144, Firefox 139 and Node 26 ship it; Safari does not yet).
- How do you decide whether to add a new npm package?
- Ask whether native JavaScript solves it, whether it is actively maintained, how big it is, how many transitive dependencies it pulls in, whether it tree-shakes, what its security history looks like, and how hard it would be to remove later.
Sources
- ESLint: Migrate to v10.x
- ESLint: Configuration files
- typescript-eslint: Linting with type information
- typescript-eslint: Dependency versions
- Microsoft: Announcing TypeScript 6.0
- Microsoft: Announcing TypeScript 7.0
- Zod 4 changelog
- Zod: Formatting errors
- ky v2.0.0 release notes
- nanoid changelog
- MSW: Migrating from 2.x to 3.x
- Vitest 4 migration guide
- Vitest migration guide (v5)
- lint-staged changelog
- concurrently v10.0.0 release notes
- dotenv changelog
- Node.js CLI docs: --env-file
- Express: Migrating to Express 5
- TC39: Temporal proposal
- MDN: Object.groupBy
- Moment.js docs (project status)
- Playwright: Page API (discouraged page.click and page.fill)
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored