SynfraCore
Synfracore
Start Learning
Navigation

Academies

Platform

RoadmapsLabsCertificationsInterviewPYQsAI AssistantCareer
Start Learning Free Learning Roadmaps

Node.jsProjects

Portfolio-ready projects to demonstrate your skills

📄
Last updated Sep 2026
Expert Content

Node.js — Portfolio Projects

Project 1: File-Organizer CLI Tool

Level: Beginner Time: 3-5 hours GitHub repo name: node-file-organizer-cli

What you build: a command-line tool that scans a directory and sorts files into subfolders by extension (.jpgimages/, .pdfdocuments/, etc.) — a real, small, genuinely useful script that exercises fs and path directly, no framework.

javascript
#!/usr/bin/env node
const fs = require("node:fs");
const path = require("node:path");

const targetDir = process.argv[2] || ".";

const categoryMap = {
  ".jpg": "images", ".png": "images", ".gif": "images",
  ".pdf": "documents", ".docx": "documents", ".txt": "documents",
  ".mp3": "audio", ".wav": "audio",
};

async function organize(dir) {
  const entries = await fs.promises.readdir(dir, { withFileTypes: true });

  for (const entry of entries) {
    if (entry.isDirectory()) continue;

    const ext = path.extname(entry.name).toLowerCase();
    const category = categoryMap[ext];
    if (!category) continue;   // leave unrecognized file types alone

    const categoryDir = path.join(dir, category);
    await fs.promises.mkdir(categoryDir, { recursive: true });

    const oldPath = path.join(dir, entry.name);
    const newPath = path.join(categoryDir, entry.name);
    await fs.promises.rename(oldPath, newPath);
    console.log(`Moved ${entry.name} -> ${category}/`);
  }
}

organize(targetDir).catch((err) => {
  console.error("Failed to organize:", err.message);
  process.exit(1);
});

Run with node organize.js ./downloads. Extend it: a --dry-run flag that logs what would move without moving anything, or reading the category map from a JSON config file instead of hardcoding it.


Project 2: Streaming File-Upload Server

Level: Intermediate Time: 6-10 hours GitHub repo name: node-stream-upload-server

What you build: an HTTP server that accepts file uploads and writes them to disk using streams — deliberately without buffering the whole upload into memory first, the same principle behind why real production upload endpoints don't just Buffer.concat an entire request body.

javascript
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const crypto = require("node:crypto");

const UPLOAD_DIR = path.join(__dirname, "uploads");
fs.mkdirSync(UPLOAD_DIR, { recursive: true });

const server = http.createServer((req, res) => {
  if (req.method === "POST" && req.url === "/upload") {
    const filename = `${crypto.randomUUID()}.bin`;
    const filePath = path.join(UPLOAD_DIR, filename);
    const writeStream = fs.createWriteStream(filePath);

    let receivedBytes = 0;
    req.on("data", (chunk) => { receivedBytes += chunk.length; });

    // req IS a readable stream -- piping it directly to disk means
    // the server never holds the full upload in memory at once
    req.pipe(writeStream);

    writeStream.on("finish", () => {
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ filename, bytes: receivedBytes }));
    });

    writeStream.on("error", (err) => {
      res.writeHead(500);
      res.end(JSON.stringify({ error: err.message }));
    });
  } else {
    res.writeHead(404);
    res.end("Not found");
  }
});

server.listen(3000, () => console.log("Upload server on :3000"));

Test with curl -X POST --data-binary @somefile.zip http://localhost:3000/upload. Extend it: a max-size limit (destroy the request and delete the partial file if it's exceeded), or basic content-type validation before accepting the stream.


Project 3: A REST API on Raw `http` (Before Express)

Level: Intermediate Time: 8-12 hours GitHub repo name: node-raw-http-rest-api

What you build: a small REST API (in-memory "todo" resource — GET/POST/PUT/DELETE /todos) using only the built-in http module — no Express. This is deliberately the hard way, and it's the point: every piece of routing, body-parsing, and status-code logic you write by hand here is exactly what Express (this platform's next Backend technology) does for you in a few lines — building it manually first is what makes that abstraction make sense later instead of feeling like unexplained magic.

javascript
const http = require("node:http");

let todos = [{ id: 1, title: "Learn Node.js", done: false }];
let nextId = 2;

function parseBody(req) {
  return new Promise((resolve, reject) => {
    let body = "";
    req.on("data", (chunk) => { body += chunk; });
    req.on("end", () => {
      try {
        resolve(body ? JSON.parse(body) : {});
      } catch (err) {
        reject(err);
      }
    });
  });
}

function sendJson(res, status, data) {
  res.writeHead(status, { "Content-Type": "application/json" });
  res.end(JSON.stringify(data));
}

const server = http.createServer(async (req, res) => {
  const { method, url } = req;

  if (method === "GET" && url === "/todos") {
    return sendJson(res, 200, todos);
  }

  if (method === "POST" && url === "/todos") {
    const body = await parseBody(req).catch(() => null);
    if (!body || !body.title) return sendJson(res, 400, { error: "title is required" });
    const todo = { id: nextId++, title: body.title, done: false };
    todos.push(todo);
    return sendJson(res, 201, todo);
  }

  const idMatch = url.match(/^\/todos\/(\d+)$/);
  if (idMatch) {
    const id = Number(idMatch[1]);

    if (method === "PUT") {
      const body = await parseBody(req).catch(() => null);
      const todo = todos.find((t) => t.id === id);
      if (!todo) return sendJson(res, 404, { error: "not found" });
      Object.assign(todo, body);
      return sendJson(res, 200, todo);
    }

    if (method === "DELETE") {
      const before = todos.length;
      todos = todos.filter((t) => t.id !== id);
      if (todos.length === before) return sendJson(res, 404, { error: "not found" });
      return sendJson(res, 204, null);
    }
  }

  sendJson(res, 404, { error: "route not found" });
});

server.listen(3000, () => console.log("Raw HTTP REST API on :3000"));

Test with curl, httpie, or Postman against each verb. Extend it: persist todos to a JSON file with fs instead of an in-memory array, or add basic input validation on PUT. When this feels tedious to extend further — routing with regex, manually parsing bodies on every handler — that tedium is exactly the motivation for the Express technology next.

Share:
Join our Community
Daily tips, job alerts, interview help — join engineers learning together
Up Next
💬
Node.jsInterview Q&A
Most asked interview questions with detailed answers
Also Worth Exploring
← Back to all Node.js modules
LabsInterview Q&A