• Follow Us On :
Node.js Tutorial

Node.js Tutorial: A Complete Guide for Beginners

JavaScript used to live entirely inside a browser tab. Node.js changed that by taking the same language and letting it run directly on a server, which is why a single language now powers both the frontend and backend of a huge share of modern web applications. This Node.js tutorial covers everything from installing it correctly to building a working REST API, with runnable code at every step.

Node.js isn’t a framework or a new version of JavaScript; it’s a runtime, built on Google’s V8 engine (the same engine that powers Chrome), that lets JavaScript execute outside a browser with access to the file system, network, and operating system that browser-based JavaScript intentionally doesn’t have.

Installing Node.js

As of mid-2026, Node.js 24 is the current Active LTS (long-term support) release, and it’s the right version to install for new projects. Node.js 22 remains supported under maintenance LTS if you’re working on an existing project, and Node.js 26 exists as the newer “Current” release line but won’t become LTS until October 2026, so it’s better suited to experimentation than production work for now.

The cleanest way to install and manage Node.js versions is through a version manager rather than installing a single global version directly:

bash
# Using nvm (Node Version Manager)
nvm install 24
nvm use 24

# Verify installation
node --version
npm --version

Using a version manager matters more than it might seem at first, since different projects often need different Node.js versions, and switching between them with a single command beats reinstalling Node.js manually every time a project’s requirements differ.

Your First Node.js Script

Node.js runs JavaScript files directly from the command line, no browser or build step required.

javascript
// hello.js
console.log('Hello from Node.js');

const name = 'World';
console.log(`Hello, ${name}!`);

Run it with:

bash
node hello.js

That’s genuinely the entire process. Unlike browser JavaScript, there’s no HTML page needed to load a script, no <script> tag, just a file and the node command.

Understanding the Node.js Module System

Node.js splits code across multiple files using a module system, and there are two you’ll encounter: CommonJS, the original system, and ES Modules, the modern JavaScript standard that Node.js now supports natively.

CommonJS (the traditional Node.js approach):

javascript
// math.js
function add(a, b) {
  return a + b;
}

module.exports = { add };
javascript
// app.js
const { add } = require('./math.js');
console.log(add(2, 3)); // 5

ES Modules, the same syntax used in modern frontend JavaScript, requires adding "type": "module" to your package.json:

javascript
// math.mjs (or math.js with "type": "module" in package.json)
export function add(a, b) {
  return a + b;
}
javascript
// app.mjs
import { add } from './math.mjs';
console.log(add(2, 3)); // 5

New projects generally default to ES Modules now, since it’s the same syntax used across the rest of the JavaScript ecosystem and keeps frontend and backend code more consistent. That said, a huge share of existing Node.js packages and codebases still use CommonJS, so recognizing both is necessary rather than optional.

The Node.js Event Loop and Non-Blocking I/O

Node.js runs JavaScript on a single thread, but it handles I/O operations, reading files, network requests, database queries, without blocking that thread while waiting for them to finish. This is the core idea that makes Node.js well suited for applications handling many simultaneous connections, like a web server.

Compare a blocking and a non-blocking approach to reading a file:

javascript
// Blocking (synchronous) - freezes everything else until this finishes
const fs = require('fs');
const data = fs.readFileSync('data.txt', 'utf8');
console.log(data);
console.log('This runs after the file is fully read');
javascript
// Non-blocking (asynchronous) - other code can run while this waits
const fs = require('fs');
fs.readFile('data.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log(data);
});
console.log('This runs immediately, before the file finishes reading');

In the second example, “This runs immediately” prints before the file’s contents, since the file read happens in the background while the rest of the script keeps running. This behavior is the entire reason Node.js can handle thousands of simultaneous connections on a single thread without grinding to a halt, since it’s not sitting idle waiting on any single slow operation.

Working With the File System

The fs module handles file operations, and its promise-based version, fs/promises, pairs cleanly with async/await for more readable code than callback-based syntax.

javascript
import { readFile, writeFile, appendFile } from 'fs/promises';

async function processFile() {
  try {
    const content = await readFile('notes.txt', 'utf8');
    console.log(content);

    await appendFile('notes.txt', '\nNew line added');
    await writeFile('backup.txt', content);
  } catch (err) {
    console.error('File operation failed:', err.message);
  }
}

processFile();

Wrapping asynchronous file operations in a try/catch block, as shown above, is worth doing consistently, since a missing file or a permissions issue will throw an error that crashes an unhandled async function silently otherwise.

Building a Basic HTTP Server

Node.js includes a built-in http module capable of running a working web server without any external framework.

javascript
import { createServer } from 'http';

const server = createServer((req, res) => {
  if (req.url === '/' && req.method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('Welcome to the homepage');
  } else if (req.url === '/about') {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('About page');
  } else {
    res.writeHead(404, { 'Content-Type': 'text/plain' });
    res.end('Page not found');
  }
});

server.listen(3000, () => {
  console.log('Server running at http://localhost:3000');
});

This works, but manually checking req.url and req.method for every route gets unwieldy fast, which is exactly the problem frameworks like Express exist to solve, covered a bit further down.

Understanding Streams

Streams let Node.js process data in chunks as it arrives, rather than loading an entire file or response into memory all at once. This matters enormously for large files or data transfers, since reading a multi-gigabyte file with readFile would load the whole thing into memory before you could do anything with it, while a stream processes it piece by piece.

javascript
import { createReadStream, createWriteStream } from 'fs';

const readStream = createReadStream('large-file.txt', { encoding: 'utf8' });
const writeStream = createWriteStream('copy.txt');

readStream.on('data', (chunk) => {
  console.log(`Received ${chunk.length} characters`);
});

readStream.on('end', () => {
  console.log('Finished reading the file');
});

// Piping connects a readable stream directly to a writable one
readStream.pipe(writeStream);

pipe() handles a genuinely tricky problem automatically: backpressure, making sure a fast readable stream doesn’t overwhelm a slower writable one by buffering more data than the destination can handle. This same streaming approach underlies how Node.js handles HTTP request and response bodies internally, which is part of why it scales well for applications serving large files or handling many concurrent uploads and downloads.

npm and Package Management

npm (Node Package Manager) installs and manages third-party libraries, and it comes bundled with Node.js automatically.

bash
# Initialize a new project, creating package.json
npm init -y

# Install a package and save it as a dependency
npm install express

# Install a package only needed during development
npm install --save-dev nodemon

# Install a specific version
npm install express@4.18.2

Your package.json tracks project dependencies and their version ranges, and package-lock.json locks the exact resolved versions installed, which is why both files should be committed to version control together, while the node_modules folder itself generally shouldn’t be.

npm scripts, defined in package.json, are worth setting up early in a project:

json
{
  "scripts": {
    "start": "node index.js",
    "dev": "nodemon index.js",
    "test": "node --test"
  }
}

Running npm run dev then executes whatever command is defined there, which is considerably easier to remember and share with a team than a long raw command typed from memory each time.

Building a REST API With Express

Express is the most widely used web framework for Node.js, and it dramatically simplifies routing, middleware, and request handling compared to the raw http module.

javascript
import express from 'express';

const app = express();
app.use(express.json());

let todos = [
  { id: 1, task: 'Learn Node.js', done: false }
];

// GET all todos
app.get('/todos', (req, res) => {
  res.json(todos);
});

// GET a single todo
app.get('/todos/:id', (req, res) => {
  const todo = todos.find(t => t.id === parseInt(req.params.id));
  if (!todo) return res.status(404).json({ error: 'Todo not found' });
  res.json(todo);
});

// POST a new todo
app.post('/todos', (req, res) => {
  const newTodo = {
    id: todos.length + 1,
    task: req.body.task,
    done: false
  };
  todos.push(newTodo);
  res.status(201).json(newTodo);
});

// PUT update a todo
app.put('/todos/:id', (req, res) => {
  const todo = todos.find(t => t.id === parseInt(req.params.id));
  if (!todo) return res.status(404).json({ error: 'Todo not found' });
  todo.done = req.body.done ?? todo.done;
  res.json(todo);
});

// DELETE a todo
app.delete('/todos/:id', (req, res) => {
  todos = todos.filter(t => t.id !== parseInt(req.params.id));
  res.status(204).send();
});

app.listen(3000, () => console.log('API running on port 3000'));

express.json() is middleware that parses incoming JSON request bodies automatically, making req.body usable directly in your route handlers rather than manually parsing raw request data yourself. This same basic structure, routes mapped to CRUD operations on some in-memory or database-backed data, is the foundation of the large majority of real-world Node.js APIs.

Custom and Error-Handling Middleware

Middleware functions run between an incoming request and your route handler, and Express lets you write your own for tasks like logging, authentication checks, or centralized error handling.

javascript
// A simple logging middleware, applied to every request
app.use((req, res, next) => {
  console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
  next(); // pass control to the next middleware or route handler
});

// Authentication middleware applied only to specific routes
function requireAuth(req, res, next) {
  const token = req.headers.authorization;
  if (!token) {
    return res.status(401).json({ error: 'Authentication required' });
  }
  next();
}

app.get('/todos', requireAuth, (req, res) => {
  res.json(todos);
});

// Error-handling middleware, defined with four parameters, always runs last
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Something went wrong' });
});

Calling next() is what moves a request along to the next middleware or the actual route handler; forgetting to call it is a common beginner mistake that leaves a request hanging with no response at all. Error-handling middleware, distinguished by its four parameters instead of the usual three, needs to be registered after all your regular routes, since Express identifies it by that specific signature and its position in the middleware chain.

Connecting to a Database

Most real applications need to persist data beyond a server restart, and Node.js has mature libraries for both SQL and NoSQL databases. Here’s a minimal example using pg, the standard PostgreSQL client:

javascript
import pg from 'pg';

const pool = new pg.Pool({
  connectionString: process.env.DATABASE_URL
});

async function getTodos() {
  const result = await pool.query('SELECT * FROM todos ORDER BY id');
  return result.rows;
}

async function createTodo(task) {
  const result = await pool.query(
    'INSERT INTO todos (task, done) VALUES ($1, false) RETURNING *',
    [task]
  );
  return result.rows[0];
}

Using parameterized queries, the $1 placeholder above rather than directly embedding a variable into the SQL string, is essential for preventing SQL injection, one of the most common and damaging vulnerabilities in real applications that build queries by concatenating user input directly into a query string. For NoSQL databases like MongoDB, the mongoose library provides a similar structure with schema-based models instead of raw SQL queries, and the choice between SQL and NoSQL generally comes down to how structured and relational your data actually is rather than one being universally better.

Basic Security Practices for Node.js Applications

A few security fundamentals are worth building into any Node.js application from the start rather than adding after an incident.

javascript
import helmet from 'helmet';
import cors from 'cors';
import rateLimit from 'express-rate-limit';

app.use(helmet()); // sets several protective HTTP headers automatically
app.use(cors({ origin: 'https://yourfrontend.com' })); // restrict cross-origin requests

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100 // limit each IP to 100 requests per window
});
app.use(limiter);

helmet sets a handful of HTTP headers that mitigate common web vulnerabilities like clickjacking and certain cross-site scripting attack vectors, with minimal configuration required. Rate limiting protects against both abuse and simple traffic spikes overwhelming your server. Beyond these middleware additions, always validate and sanitize user input before using it in a database query or file path, and never log sensitive data like passwords or full API keys, since logs are often less tightly secured than the primary database itself. Our Cloud Security Best Practices guide covers the broader infrastructure-level practices that complement these application-level ones.

Handling Asynchronous Code Properly

Real applications constantly deal with asynchronous operations, database queries, API calls, file reads, and how you handle them affects both readability and reliability.

javascript
// Callback style - works, but nests badly with multiple steps
function getUser(id, callback) {
  database.find(id, (err, user) => {
    if (err) return callback(err);
    callback(null, user);
  });
}

// Promise style - flatter, chainable
function getUser(id) {
  return database.find(id);
}

getUser(1)
  .then(user => console.log(user))
  .catch(err => console.error(err));

// async/await - reads like synchronous code
async function getUserData(id) {
  try {
    const user = await database.find(id);
    console.log(user);
  } catch (err) {
    console.error('Failed to fetch user:', err.message);
  }
}

async/await has become the standard approach for new code, since it reads top-to-bottom without the visual nesting that callbacks and even promise chains can accumulate once several asynchronous steps depend on each other. Wrapping await calls in try/catch is essential, not optional, since an unhandled rejection inside an async function can otherwise crash the process depending on how your application is configured.

Environment Variables and Configuration

Configuration values, database URLs, API keys, port numbers, should never be hardcoded directly into your source code, particularly anything sensitive.

javascript
// Node.js 20+ supports loading .env files natively
// Run with: node --env-file=.env app.js

console.log(process.env.DATABASE_URL);
console.log(process.env.PORT || 3000);
bash
# .env file
DATABASE_URL=postgres://localhost:5432/mydb
PORT=3000
API_KEY=your-secret-key-here

Native .env file support removed the need for the dotenv package in many projects, though dotenv remains widely used and still works fine, particularly in codebases that haven’t migrated to newer Node.js versions yet. Either way, .env files should always be added to .gitignore, since committing secrets to version control is one of the more common and preventable security mistakes in real projects.

Testing With Node.js’s Built-In Test Runner

Node.js includes a native test runner, removing the need to install a separate testing framework for many projects.

javascript
// sum.test.js
import { test } from 'node:test';
import assert from 'node:assert';

function sum(a, b) {
  return a + b;
}

test('sum adds two numbers correctly', () => {
  assert.strictEqual(sum(2, 3), 5);
});

test('sum handles negative numbers', () => {
  assert.strictEqual(sum(-1, -1), -2);
});

Run it with:

bash
node --test

For simple to medium-sized projects, the built-in runner is genuinely sufficient. Larger projects, or ones needing more advanced mocking and assertion libraries, still commonly reach for Jest or Vitest, but it’s worth knowing the native option exists rather than assuming a third-party dependency is always required just to write a test.

Debugging and Development Workflow

Node.js includes a --watch flag that automatically restarts your application whenever a file changes, removing the need for a separate tool during development for many projects.

bash
node --watch index.js

For actual debugging beyond console.log, Node.js’s built-in inspector works with Chrome DevTools directly:

bash
node --inspect index.js

Then open chrome://inspect in Chrome to attach a full debugger with breakpoints, step-through execution, and variable inspection, which is considerably more effective for tracking down a subtle bug than scattering console statements throughout your code and rerunning repeatedly.

The process Object and Common Globals

Node.js provides several global objects available in every file without needing to import them, and process is the one you’ll use most often.

javascript
console.log(process.argv);      // command-line arguments passed to the script
console.log(process.env.NODE_ENV); // current environment (development, production, etc.)
console.log(process.platform);  // 'darwin', 'linux', 'win32', etc.

process.on('exit', (code) => {
  console.log(`Process exiting with code ${code}`);
});

// Gracefully exit with a specific status code
if (someFailureCondition) {
  process.exit(1);
}

One common point of confusion when switching to ES Modules: __dirname and __filename, both available by default in CommonJS, don’t exist in ES Modules. The equivalent in ESM uses import.meta.url:

javascript
// CommonJS
console.log(__dirname);

// ES Modules equivalent
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__dirname);

This trips up a lot of people migrating an older CommonJS project to ES Modules, since the error message when __dirname is undefined in an ESM file doesn’t always make the actual cause obvious right away.

Common Node.js Mistakes to Avoid

Blocking the event loop with synchronous operations. Using fs.readFileSync or a heavy synchronous computation inside a request handler freezes the entire server for every connected client until it finishes, not just the one request that triggered it.

Not handling promise rejections. An async function that throws without a surrounding try/catch, or a promise chain missing a .catch(), can cause unexpected crashes or silent failures depending on your Node.js version and configuration.

Mixing CommonJS and ES Modules carelessly. Forgetting to set "type": "module" in package.json, or mixing require() and import inconsistently across a project, produces confusing errors that are usually a module system mismatch rather than a logic bug.

Not validating user input. Directly using req.body or req.params values in a database query or file path without validation opens the door to injection attacks and unexpected crashes from malformed input.

Leaving unclosed database connections or event listeners. These accumulate over time in a long-running server process and are a common, hard-to-trace source of gradually increasing memory usage in production applications.

Practice Project

A solid way to apply everything in this Node.js tutorial: extend the to-do list API above by adding a simple file-based persistence layer using fs/promises, so data survives a server restart instead of living only in memory, then add basic input validation to the POST and PUT routes to reject requests missing a required task field. That single exercise touches routing, asynchronous file handling, and the kind of input validation every real API needs.

Frequently Asked Questions

Is Node.js a programming language?

No. Node.js is a runtime environment that executes JavaScript outside a browser. The language is still JavaScript; Node.js just provides additional capabilities, file system access, networking, and process control, that browser-based JavaScript doesn’t have for security reasons.

Do I need to learn Express, or is plain Node.js enough?

Plain Node.js can build a working server, as shown earlier, but Express (or a similar framework) handles routing, middleware, and request parsing with far less boilerplate. For anything beyond a very small project, learning Express is worth the relatively short time investment.

What’s the difference between Node.js and Deno or Bun?

All three run JavaScript outside a browser, but Deno and Bun are newer runtimes built with different priorities, Deno focuses on built-in security permissions and native TypeScript support, while Bun emphasizes raw performance and an all-in-one toolchain. Node.js remains the most widely adopted of the three, with by far the largest existing ecosystem of packages and production usage.

Which Node.js version should I use for a new project?

The current Active LTS version, which is Node.js 24 as of mid-2026. LTS versions receive the longest support window and are what most hosting providers and package maintainers test against, making them the safer default over the newer, shorter-lived “Current” release line.

Is Node.js good for CPU-intensive tasks?

Not natively, no. Node.js’s single-threaded model is well suited to I/O-heavy workloads (handling many simultaneous requests, mostly waiting on databases or external APIs) but a genuinely CPU-intensive task, like heavy image processing, will block the event loop unless it’s offloaded to a worker thread or a separate process specifically designed to handle that load.

Should I use SQL or NoSQL with Node.js?

Neither is universally better; it depends on your data. Relational databases like PostgreSQL fit well-structured, relational data with clear schemas, while NoSQL databases like MongoDB suit more flexible, document-shaped data that doesn’t fit neatly into fixed tables. Node.js has mature, well-supported libraries for both, so the decision should come from your data’s actual shape rather than a general trend.

How is a Node.js application typically deployed to production?

Common approaches include containerizing the application with Docker and deploying to a cloud platform, or using a managed platform-as-a-service option that handles much of the infrastructure directly. Regardless of the deployment method, using a process manager like PM2, or relying on your platform’s built-in process supervision, keeps the application running and automatically restarts it if it crashes, which matters far more in production than it does during local development.

Where to Go From Here

This Node.js tutorial covered installation, the module system, the event loop, file handling, building both a raw HTTP server and an Express-based REST API, asynchronous patterns, environment configuration, and native testing, all with code you can run directly. From here, the fastest way to build real fluency is extending a small project like the to-do API above with a real database, since that’s where async patterns and error handling actually start to matter in practice.

If you want to strengthen the JavaScript fundamentals this tutorial assumes, our JavaScript interview questions guide covers the core language concepts, closures, the event loop, promises, that carry directly into Node.js work. Our CSS tutorial rounds out the front-end side if you’re building a full-stack project. For complete reference on every module and API mentioned here, the official Node.js documentation and Express.js documentation are both worth bookmarking.

Leave a Reply

Your email address will not be published. Required fields are marked *