Node.js Crash Course (Full Notes)
Complete written notes for the Node.js crash course: the event loop, npm, core modules, the raw HTTP module vs. Express, middleware, and building a full CRUD job-tracker API with routes and controllers.
- 01What Node.js actually is
- 02The event loop, in one example
- 03Starting a project with npm
- 04Core modules: fs and path
- 05A server with the raw http module
- 06Express: the same server, radically simpler
- Dynamic routes and query params
- 07Middleware
- 08Structuring a real app: routes and controllers
- 09Building the CRUD API
- GET /applications — list, filter, sort, paginate
- GET /applications/:id — a single record
- POST /applications — create
- PUT /applications/:id — update
- DELETE /applications/:id — remove
- GET /applications/stats — a small aggregation
- 10Recap
Full written notes to follow along with the video, or come back to as a reference later. By the end, you'll have built a small job-tracker REST API with Express: routing, middleware, and full CRUD, backed by a JSON file instead of a database to keep the focus on Node and Express themselves.
This course intentionally skips a database and auth to stay focused on core Node.js and Express concepts. Data is read from and written to a local JSON file instead.
What Node.js actually is
JavaScript was originally built to run inside a browser. Node.js takes the same V8 engine that powers Chrome and lets it run outside the browser, as a standalone program on your machine or a server. That's what makes it possible to read and write files, start servers, talk to databases, and build CLI tools in JavaScript.
Install it from nodejs.org, then confirm it worked:
node -vv22.11.0
Running a file is just:
node index.jsThe event loop, in one example
This trips up almost everyone learning Node for the first time. Run this:
console.log("order");
setTimeout(() => console.log("cooking"), 0);
console.log("next order");order next order cooking
Even with a 0ms delay, "cooking" prints last. JavaScript is single-threaded, so it always finishes the synchronous code first, and only picks up asynchronous work (timers, I/O, network calls) once the main thread is free.
Output order: order → next order → cooking
Think of it like a kitchen: the chef takes an order (console.log("order"), instant), starts cooking it in the background, and immediately takes the next order (console.log("next order"), also instant) instead of standing around waiting. Only once the synchronous line is clear does the finished dish ("cooking") get called out.
Starting a project with npm
Node comes bundled with npm (Node Package Manager).
npm initWalk through the prompts (name, version, entry point, and so on). One field matters more than the others: type. Set it to commonjs to use require/module.exports throughout this course, since that's what all the examples below use. The other option, module, switches you to ES module import/export syntax instead, but mixing the two will crash your app, so pick one.
This generates package.json, which tracks every dependency you install:
npm install express
npm install nodemonAdd a dev script so the server restarts automatically on file changes instead of you re-running node index.js by hand:
{
"scripts": {
"dev": "nodemon index.js"
}
}npm run devCore modules: fs and path
fs reads and writes files. path builds correct file paths regardless of OS.
const fs = require("fs");
const path = require("path");
const filePath = path.join(__dirname, "data.json");Reading, synchronous vs. asynchronous:
// Synchronous: blocks the thread until the read finishes
const data = fs.readFileSync(filePath, "utf-8");
console.log(data);// Asynchronous: preferred in real servers, doesn't block other requests
fs.readFile(filePath, "utf-8", (err, data) => {
if (err) throw err;
console.log(data);
});The synchronous version is fine for one-off scripts and tooling. In a real server, prefer the asynchronous version so one slow read doesn't stall every other incoming request.
Writing works the same way, with a synchronous and asynchronous variant:
const newData = { applications: [/* ... */] };
fs.writeFile(filePath, JSON.stringify(newData, null, 2), (err) => {
if (err) throw err;
console.log("async write complete");
});async write complete
The null, 2 arguments to JSON.stringify mean "no custom replacer, and indent with 2 spaces" — that's what keeps the written file human-readable instead of one long line.
A server with the raw http module
Before Express, this is what a server looks like with nothing but Node's built-in http module:
const http = require("http");
const server = http.createServer((request, response) => {
if (request.url === "/" && request.method === "GET") {
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("Welcome to Job Tracker API.");
} else if (request.url === "/health" && request.method === "GET") {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({ status: "ok" }));
} else {
response.writeHead(404, { "Content-Type": "text/plain" });
response.end("Not found.");
}
});
server.listen(3000, () => {
console.log("Server running on localhost:3000");
});request.method is one of four values you'll use constantly: GET (read), POST (create), PUT (update), DELETE (remove). The status code you send back matters too: 2xx for success, 4xx for a client error (bad request, not found), 5xx for a server error.
This works, but scales badly. Writing an if/else if chain for even 15 endpoints is a nightmare to read and maintain. That gap is exactly what Express exists to fix.
Express: the same server, radically simpler
If raw Node.js is bricks, Express is pre-built walls you snap together.
const express = require("express");
const app = express();
app.get("/", (request, response) => {
response.send("Welcome to Job Tracker API.");
});
app.listen(3000, () => {
console.log("Server running on port 3000");
});Same behavior as the http version above, in a fraction of the code, and no manual if/else if routing.
Dynamic routes and query params
A route segment prefixed with : becomes a named parameter you can read from request.params:
app.get("/applications/:id", (request, response) => {
response.send(`Fetching application with ID ${request.params.id}`);
});Query string values (?status=applied) come from request.query instead:
app.get("/applications", (request, response) => {
const { status } = request.query;
console.log(`Filtering by status ${status}`);
// ...
});Hitting /applications?status=applied logs Filtering by status applied.
Middleware
Middleware is a function that runs between the incoming request and your final response. Every app.use(...) call registers one.
Each middleware must call next() to pass the request along — skip it, and the request hangs forever.
app.use(express.json());This parses incoming JSON bodies automatically, so request.body is a usable object instead of raw text, and it only kicks in for requests whose Content-Type is actually JSON.
A custom logging middleware looks like this:
app.use((request, response, next) => {
console.log(request.method, request.url);
next();
});GET /applications
Forgetting to call next() is the single most common beginner mistake with Express middleware. Without it, the request just hangs forever and never reaches your route handler.
Error-handling middleware takes four arguments instead of three, and Express detects it by that signature alone. It belongs right before app.listen, after every other route and middleware:
app.use((error, request, response, next) => {
console.error(error);
response.status(500).json({ success: false, error: "Something went wrong" });
});Structuring a real app: routes and controllers
Everything so far lived in one file. Real apps split routing (which URL maps to which handler) from controllers (the actual logic). Rename your entry file to app.js, and create two folders:
app.js
routes/
applications.routes.js
controllers/
applications.controller.js
data.json
app.js wires everything together:
const express = require("express");
const app = express();
app.use(express.json());
app.use((request, response, next) => {
console.log(request.method, request.url);
next();
});
app.get("/", (request, response) => {
response.send("Welcome to the job tracker API.");
});
const applicationsRouter = require("./routes/applications.routes");
app.use("/applications", applicationsRouter);
// 404 fallback, after every real route
app.use((request, response) => {
response.status(404).json({ success: false, error: "Route not found" });
});
// Error handler, always last
app.use((error, request, response, next) => {
console.error(error);
response.status(500).json({ success: false, error: "Internal server error" });
});
app.listen(3000, () => {
console.log("Job tracker API running on localhost:3000");
});express.Router() lets a routes file define sub-routes relative to whatever prefix it's mounted under (/applications here, from the app.use("/applications", applicationsRouter) line above):
const express = require("express");
const router = express.Router();
const controller = require("../controllers/applications.controller");
router.get("/", controller.getAllApplications);
router.get("/stats", controller.getStats);
router.get("/:id", controller.getApplicationById);
router.post("/", controller.createApplication);
router.put("/:id", controller.updateApplication);
router.delete("/:id", controller.deleteApplication);
module.exports = router;Route order matters. If /:id is registered before /stats, Express matches /stats as if "stats" were an :id value, since it reads routes top to bottom and stops at the first match. Always put specific static routes (/stats) above dynamic ones (/:id).
Building the CRUD API
The controller reads and writes data.json directly, so there's no real database involved. Two small helpers do the file I/O:
const fs = require("fs");
const path = require("path");
const dataFile = path.join(__dirname, "../data.json");
function readData() {
const raw = fs.readFileSync(dataFile, "utf-8");
return JSON.parse(raw).applications;
}
function writeData(applications) {
fs.writeFileSync(dataFile, JSON.stringify({ applications }, null, 2));
}Two more helpers keep every controller's response shape consistent:
function sendSuccess(response, status, data) {
response.status(status).json({ success: true, data });
}
function sendError(response, status, message) {
response.status(status).json({ success: false, error: message });
}GET /applications — list, filter, sort, paginate
/applications?status=applied{
"applications": [
{ "id": 1, "company": "Meta", "status": "applied" },
{ "id": 2, "company": "OpenAI", "status": "interview" }
]
}{
"success": true,
"data": [
{ "id": 1, "company": "Meta", "status": "applied" }
]
}exports.getAllApplications = (request, response) => {
const { status, sort, page, limit } = request.query;
let result = readData();
if (status) {
result = result.filter((application) => application.status === status);
}
if (sort === "date") {
result = [...result].sort((a, b) =>
a.appliedDate > b.appliedDate ? -1 : 1
);
}
if (page && limit) {
const pageNum = Number(page);
const limitNum = Number(limit);
const start = (pageNum - 1) * limitNum;
result = result.slice(start, start + limitNum);
}
sendSuccess(response, 200, result);
};Pagination math: page 1 with a limit of 10 slices [0, 10), page 2 slices [10, 20), and so on — start is always (pageNum - 1) * limitNum.
GET /applications/:id — a single record
/applications/2{
"applications": [
{ "id": 1, "company": "Meta" },
{ "id": 2, "company": "OpenAI" }
]
}{
"success": true,
"data": { "id": 2, "company": "OpenAI" }
}exports.getApplicationById = (request, response) => {
const id = Number(request.params.id);
const application = readData().find((app) => app.id === id);
if (!application) {
return sendError(response, 404, "Application not found");
}
sendSuccess(response, 200, application);
};POST /applications — create
/applications{
"company": "Nvidia",
"role": "SWE",
"status": "interview"
}{
"applications": [
{ "id": 1, "company": "Meta" }
]
}{
"applications": [
{ "id": 1, "company": "Meta" },
{ "id": 2, "company": "Nvidia", "role": "SWE", "status": "interview" }
]
}{
"success": true,
"data": { "id": 2, "company": "Nvidia", "role": "SWE", "status": "interview" }
}exports.createApplication = (request, response) => {
const { company, role, status } = request.body;
if (!company || !role) {
return sendError(response, 400, "Company and role are required");
}
const applications = readData();
const nextId =
applications.length > 0
? Math.max(...applications.map((app) => app.id)) + 1
: 1;
const newApplication = {
id: nextId,
company,
role,
status: status || "applied",
appliedDate: new Date().toISOString().split("T")[0],
};
applications.push(newApplication);
writeData(applications);
sendSuccess(response, 201, newApplication);
};The next ID is always one more than the current highest ID, or 1 if there are no applications yet.
PUT /applications/:id — update
/applications/2{ "company": "OpenAI" }{
"applications": [
{ "id": 2, "company": "Nvidia", "status": "interview" }
]
}{
"applications": [
{ "id": 2, "company": "OpenAI", "status": "interview" }
]
}{
"success": true,
"data": { "id": 2, "company": "OpenAI", "status": "interview" }
}exports.updateApplication = (request, response) => {
const id = Number(request.params.id);
const applications = readData();
const application = applications.find((app) => app.id === id);
if (!application) {
return sendError(response, 404, "Application not found");
}
const { company, role, status } = request.body;
if (company !== undefined) application.company = company;
if (role !== undefined) application.role = role;
if (status !== undefined) application.status = status;
writeData(applications);
sendSuccess(response, 200, application);
};application here is a reference into the same array applications holds, so mutating its fields directly also updates it inside applications — no need to reassign or rebuild the array before calling writeData.
DELETE /applications/:id — remove
/applications/2{
"applications": [
{ "id": 1, "company": "Meta" },
{ "id": 2, "company": "OpenAI" }
]
}{
"applications": [
{ "id": 1, "company": "Meta" }
]
}204 No Contentexports.deleteApplication = (request, response) => {
const id = Number(request.params.id);
const applications = readData();
const index = applications.findIndex((app) => app.id === id);
if (index === -1) {
return sendError(response, 404, "Application not found");
}
applications.splice(index, 1);
writeData(applications);
response.status(204).end();
};204 No Content means the request succeeded and there's deliberately no response body, so it ends the response directly instead of calling sendSuccess.
GET /applications/stats — a small aggregation
Left as an exercise in the video, but the shape is a straightforward reduce:
exports.getStats = (request, response) => {
const stats = readData().reduce((counts, application) => {
counts[application.status] = (counts[application.status] || 0) + 1;
return counts;
}, {});
sendSuccess(response, 200, stats);
};Testing these endpoints from a browser only works for GET requests. For POST/PUT/DELETE, use a REST client instead: Thunder Client (a free VS Code extension) or Postman both work well, and let you set the request body and headers directly.
Recap
- Node.js runs JavaScript outside the browser using Chrome's V8 engine, which is what makes file I/O, servers, and CLI tools possible in JS.
- The event loop always finishes synchronous code before picking up anything asynchronous, even a
setTimeoutof 0ms. fsandpathare the core modules for reading/writing files; prefer the asynchronous versions in real servers so one slow operation doesn't block others.- Raw
http.createServerworks but scales badly past a handful of routes. Express replaces the manualif/else ifchain withapp.get/app.post/etc. - Middleware (
app.use) runs between request and response. Always callnext(), or the request hangs forever. - Route order matters: static routes (
/stats) must come before dynamic ones (/:id) on the same path. - Real apps split routing from logic: routes files map URLs to controller functions; controllers hold the actual logic.
Want to see a full production-style API next?
This crash course intentionally skips a database and auth to stay focused on Node and Express fundamentals. Let me know in the video comments if you want a follow-up with a real database, authentication, and TypeScript.
Watch more RoadsideCoder tutorials