There are thousands of Node.js packages on npm, but a production backend usually depends on a much smaller set of tools.
Over the years, I have become more selective about adding dependencies. I no longer install a package simply because it appears in a “top Node.js libraries” list. Every dependency adds something else to maintain, update, secure, and understand.
So this is not another list of 30 popular npm packages. These are the kinds of Node.js libraries I would actually consider when building APIs, SaaS products, background workers, authentication systems, and other backend applications today.
For each library, I will explain where I use it, why I choose it, and when I would avoid it.
My Node.js Library Shortlist
| Library | What I Use It For |
|---|---|
| Fastify | REST APIs and backend services |
| Zod | Runtime validation and TypeScript schemas |
| Pino | Structured production logging |
| Prisma | Type-safe database access |
| pg | Direct PostgreSQL access |
| BullMQ | Queues and background jobs |
| Socket.IO | Real-time communication |
| Nodemailer | Sending transactional email |
| Vitest | Unit and integration testing |
| Argon2 | Password hashing |
1. Fastify — My Choice for Many Lightweight APIs
Express is still one of the best-known Node.js frameworks, and I have worked with it extensively. But when I start a lightweight API today, Fastify is often one of the first frameworks I consider.
I like Fastify because it gives me routing, plugins, schema-based validation, structured logging support, and a clean lifecycle without forcing a huge application architecture on the project.
Install it:
npm install fastify
A minimal TypeScript server can be very small:
import Fastify from 'fastify';
const app = Fastify({
logger: true,
});
app.get('/health', async () => {
return {
status: 'ok',
};
});
async function start() {
try {
await app.listen({
port: 3000,
host: '0.0.0.0',
});
} catch (error) {
app.log.error(error);
process.exit(1);
}
}
start();
One detail I like is that logging fits naturally into the framework:
app.get('/users/:id', async (request) => {
request.log.info(
{ params: request.params },
'Fetching user'
);
return {
id: 10,
name: 'Jay',
};
});
When I use Fastify
- REST APIs
- small and medium microservices
- internal APIs
- services where I want minimal framework overhead
- TypeScript backends that do not need NestJS-level architecture
When I might choose something else
If the project has a large team, many domains, guards, dependency injection, and complex application architecture, I may prefer NestJS.
For extremely small edge-oriented services, I may consider something even lighter such as Hono.
The framework choice should follow the application requirements, not benchmarks alone.
2. Zod — Validate Data at Application Boundaries
TypeScript types disappear at runtime.
That means this interface:
interface UserInput {
name: string;
email: string;
}
does not protect your API from receiving this:
{
"name": 123,
"email": false
}
This is where runtime validation becomes important.
Zod is one of my preferred options because I can define a validation schema and infer the TypeScript type from the same source.
npm install zod
import { z } from 'zod';
const CreateUserSchema = z.object({
name: z
.string()
.min(2)
.max(100),
email: z
.string()
.email(),
age: z
.number()
.int()
.positive()
.optional(),
});
type CreateUserInput =
z.infer<typeof CreateUserSchema>;
Now untrusted input can be validated before it reaches the business layer:
const result =
CreateUserSchema.safeParse(
request.body
);
if (!result.success) {
return {
error: 'Invalid request',
issues: result.error.issues,
};
}
const user = result.data;
I especially like this approach for API boundaries, environment configuration, webhook payloads, third-party API responses, and form submissions.
One mistake I avoid
Do not validate only data coming from the browser.
Anything outside your application’s trusted boundary can be wrong:
- webhook payloads
- environment variables
- third-party API responses
- queue messages
- uploaded JSON
TypeScript does not automatically make external data safe.
3. Pino — Structured Logging Instead of console.log
console.log() is fine during development, but production systems need logs that can actually be searched and analyzed.
I prefer structured JSON logging for backend applications.
npm install pino
import pino from 'pino';
const logger = pino({
level:
process.env.LOG_LEVEL ??
'info',
});
logger.info(
{
userId: 42,
action: 'user_login',
},
'User logged in'
);
This is much more useful than:
console.log(
'User 42 logged in'
);
With structured fields, systems such as CloudWatch, Datadog, Grafana, Elasticsearch, or other log platforms can filter events much more easily.
Never log secrets
Logging everything is not good observability.
I avoid logging:
- passwords
- JWTs
- refresh tokens
- API keys
- authorization headers
- sensitive personal data unless genuinely required
Good logging helps you investigate problems without creating a second security problem.
4. Prisma — Productive Type-Safe Database Access
For TypeScript applications where developer productivity and type safety are priorities, Prisma remains a database tool I consider seriously.
A Prisma model might look like:
model User {
id Int @id @default(autoincrement())
name String
email String @unique
createdAt DateTime @default(now())
}
Then application code remains readable:
const user =
await prisma.user.create({
data: {
name: 'Jay',
email: 'jay@example.com',
},
});
One of the reasons I like this style is that the database layer remains strongly connected to the TypeScript development experience.
Where Prisma works well for me
- SaaS applications
- CRUD-heavy APIs
- small and medium development teams
- projects where schema migrations need to be managed consistently
- TypeScript-first backends
When I do not automatically choose an ORM
If an application relies heavily on complex SQL, analytics queries, database-specific functionality, or extreme query tuning, sometimes direct SQL or a query builder is clearer.
An ORM should make database work easier. It should not make developers afraid to understand SQL.
5. pg — When I Want Direct PostgreSQL Control
Not every Node.js project needs an ORM.
When PostgreSQL is the database and I want direct SQL control, pg is often enough.
npm install pg
import { Pool } from 'pg';
const pool = new Pool({
connectionString:
process.env.DATABASE_URL,
});
const result =
await pool.query(
`
SELECT id, name, email
FROM users
WHERE email = $1
LIMIT 1
`,
['jay@example.com']
);
console.log(result.rows[0]);
Notice the parameter:
WHERE email = $1
and the value is provided separately:
['jay@example.com']
That is the style I use instead of building SQL by concatenating user input.
Prisma or pg?
I do not treat this as a competition.
My decision is normally:
Want strong ORM productivity?
→ Prisma
Want direct PostgreSQL + SQL?
→ pg
Need both?
→ Sometimes use an ORM for normal CRUD
and raw SQL where it makes sense.
6. BullMQ — Background Jobs That Should Not Block Requests
A common backend mistake is doing too much work inside the HTTP request.
Imagine a registration endpoint that:
- creates the user
- sends an email
- generates a PDF
- updates analytics
- syncs a CRM
The user should not necessarily wait for all of that work to finish.
This is where job queues become useful.
npm install bullmq ioredis
Producer:
import { Queue } from 'bullmq';
const emailQueue =
new Queue('email', {
connection: {
host: 'localhost',
port: 6379,
},
});
await emailQueue.add(
'welcome-email',
{
userId: 42,
email: 'jay@example.com',
}
);
Worker:
import { Worker } from 'bullmq';
const worker =
new Worker(
'email',
async (job) => {
if (
job.name ===
'welcome-email'
) {
await sendWelcomeEmail(
job.data.email
);
}
},
{
connection: {
host: 'localhost',
port: 6379,
},
}
);
I commonly consider queues for:
- email sending
- image processing
- report generation
- webhook retries
- scheduled jobs
- data synchronization
- long-running imports
One important production consideration
Once a queue becomes part of your architecture, think about retries, duplicate processing, failed jobs, monitoring, and idempotency.
A queue does not automatically make unreliable code reliable.
7. Socket.IO — When the Product Truly Needs Real-Time Communication
Not every application needs WebSockets.
But for chat, live dashboards, multiplayer interactions, presence indicators, or real-time notifications, Socket.IO can remove a lot of plumbing.
npm install socket.io
import { Server } from 'socket.io';
const io = new Server(3001, {
cors: {
origin:
'https://example.com',
},
});
io.on(
'connection',
(socket) => {
console.log(
'Connected:',
socket.id
);
socket.on(
'join-room',
(roomId) => {
socket.join(roomId);
}
);
socket.on(
'disconnect',
() => {
console.log(
'Disconnected:',
socket.id
);
}
);
}
);
You can then publish an event to a room:
io
.to('project-123')
.emit(
'project-updated',
{
projectId: 123,
}
);
The mistake I avoid is introducing real-time infrastructure when polling every 30 seconds would be completely acceptable.
Real-time systems add connection management, scaling, authentication, reconnection behavior, and operational complexity. Use them when the product benefits from real-time behavior.
8. Nodemailer — Straightforward Transactional Email
Many backend applications eventually need to send email:
- password reset messages
- email verification
- invoices
- notifications
- contact form notifications
Nodemailer remains a straightforward option when I need to send email through SMTP.
npm install nodemailer
import nodemailer from 'nodemailer';
const transporter =
nodemailer.createTransport({
host:
process.env.SMTP_HOST,
port:
Number(
process.env.SMTP_PORT
),
secure: true,
auth: {
user:
process.env.SMTP_USER,
pass:
process.env.SMTP_PASSWORD,
},
});
await transporter.sendMail({
from:
'Coding With Jay <noreply@example.com>',
to:
'customer@example.com',
subject:
'Welcome',
text:
'Thanks for joining.',
});
For larger products, I may use services such as Amazon SES, Postmark, Resend, Mailgun, or another transactional provider, but Nodemailer can still sit behind the email layer when SMTP is the transport.
Do not send complex email workflows directly inside requests
For important transactional messages, I generally prefer:
API Request
↓
Database Transaction
↓
Queue Job
↓
Email Worker
↓
SMTP / Email Provider
This keeps temporary email-provider problems from making the entire user request fail.
9. Vitest — Fast Tests for TypeScript Projects
I do not consider testing an optional “later” task for important backend logic.
Vitest is one testing tool I like for modern TypeScript projects because the API is simple and familiar.
npm install -D vitest
Example:
import {
describe,
expect,
it,
} from 'vitest';
function calculateTax(
amount: number,
rate: number
) {
return amount * rate;
}
describe(
'calculateTax',
() => {
it(
'calculates tax correctly',
() => {
expect(
calculateTax(
100,
0.18
)
).toBe(18);
}
);
}
);
In backend projects I usually focus tests on things that would hurt if they broke:
- business rules
- permissions
- payment calculations
- validation
- authentication
- database behavior
- API contracts
I do not chase 100% coverage simply to produce a number. I would rather have meaningful tests around important behavior.
10. Argon2 — Password Hashing
If an application stores passwords, the passwords should never be stored as plain text.
For new Node.js applications, Argon2 is one password-hashing option I consider.
npm install argon2
Hashing a password:
import argon2 from 'argon2';
const password =
'user-password';
const hash =
await argon2.hash(
password
);
Verifying it later:
const valid =
await argon2.verify(
hash,
password
);
if (!valid) {
throw new Error(
'Invalid credentials'
);
}
Your database stores the resulting password hash, not the original password.
Password security is larger than one library, however. Production authentication should also consider rate limiting, account recovery, secure sessions or tokens, credential-stuffing protection, secret management, and multi-factor authentication where appropriate.
Libraries I No Longer Install Automatically
Some packages are useful, but modern Node.js has reduced the need for a few dependencies I used to install almost automatically.
Axios
I still use Axios in projects where its interceptors, configuration style, existing abstractions, or ecosystem integrations are useful.
But for a simple HTTP request, I no longer install Axios automatically because modern Node.js provides fetch().
const response =
await fetch(
'https://api.example.com/users'
);
if (!response.ok) {
throw new Error(
`Request failed: ${
response.status
}`
);
}
const users =
await response.json();
Fewer dependencies can be a feature.
Moment.js
I would not choose Moment.js by default for a new Node.js project today.
For basic date formatting and manipulation, native JavaScript APIs may already be enough. When I need a date library, I evaluate the actual requirements rather than importing a large package automatically.
dotenv
dotenv is still widely used and perfectly reasonable in many projects.
But I also check what the Node.js runtime and deployment platform already provide before introducing configuration dependencies.
The larger lesson is simple: a library being popular does not mean every application needs it.
How I Choose a Node.js Library Before Adding It
I normally ask several questions before introducing a dependency.
1. Does the problem actually require a library?
If Node.js already solves the problem clearly, I may not add another dependency.
2. Is the project actively maintained?
I look at releases, issues, documentation, repository activity, and whether the library supports the Node.js versions I intend to run.
3. What happens if I need to remove it?
I prefer keeping third-party libraries behind small application boundaries.
For example:
Application
↓
EmailService
↓
Nodemailer
↓
SMTP Provider
instead of calling Nodemailer directly from 25 different controllers.
If the provider changes later, the blast radius is much smaller.
4. Is it safe to use?
I check dependencies, security advisories, release history, and whether the package needs unnecessary access to sensitive parts of the application.
5. Will the team understand it?
A clever library that only one developer understands can be more expensive than straightforward code.
What My Typical Node.js Backend Stack Looks Like
There is no single stack I use for every project, but a medium-sized TypeScript API might look something like this:
Node.js
+
TypeScript
+
Fastify or NestJS
+
Zod / framework validation
+
PostgreSQL
+
Prisma or pg
+
Pino
+
BullMQ + Redis when jobs are needed
+
Vitest
+
Docker
+
AWS / another cloud platform
If the application needs real-time communication, I may add Socket.IO.
If it sends transactional emails, I add an email abstraction backed by Nodemailer or the chosen provider SDK.
If it does not need a queue, WebSockets, or an ORM, I do not install them just because they appear in my usual toolkit.
The Biggest Change in How I Choose Dependencies
Earlier in my career, I often looked for a package as soon as I encountered a problem.
Now I usually start with a different question:
What is the simplest reliable way to solve this problem?
Sometimes the answer is a mature library.
Sometimes the answer is a native Node.js API.
And sometimes the right answer is changing the architecture so the problem disappears entirely.
That mindset has helped me keep backend systems easier to upgrade and maintain.
Final Thoughts
The best Node.js library is not necessarily the one with the most downloads or GitHub stars.
It is the one that solves a real problem in your application without adding more complexity than the problem itself.
If I were starting a TypeScript backend today, my shortlist would begin with tools such as Fastify, Zod, Pino, PostgreSQL, Prisma or pg, and Vitest. Then I would add queues, real-time communication, email, or other infrastructure only when the product requires it.
Do not build your application by collecting npm packages.
Start with the architecture, understand the problem, and add dependencies deliberately.
If you are deciding between a structured Node.js framework and something lighter, you may also find my NestJS vs Hono comparison useful.




