Tools & Workflow

Your first Node.js server: static files and a JSON API with no framework

What Node.js is, how to run a script, a working HTTP server that serves files safely and answers a JSON endpoint, where frameworks like Express fit in, and how to keep it running.

By the TechTutorZone editorial team · Published · 6 min read (1,126 words)

Node.js runs JavaScript outside the browser, which makes it the natural first server language for someone who already writes front-end code. This tutorial builds a small server with only the modules that come with Node: it serves the files in a folder and answers a JSON request. Doing it once without a framework shows what Express and its relatives actually do for you, and gives you a server you fully understand. It was tested on Node.js 22 LTS; anything from 18 onwards works.

Install and run

Install the LTS version from nodejs.org (or with a version manager such as nvm or fnm if you will need several versions). Check it:

$ node --version
v22.11.0
$ echo 'console.log(1 + 1)' > hello.js
$ node hello.js
2

Node runs a file top to bottom and exits when there is nothing left to do. A server keeps it alive because the listening socket is "something left to do". Create a project folder with npm init -y, which writes a package.json; add "type": "module" to it so you can use import instead of require.

The smallest server

// server.js
import { createServer } from 'node:http';

const server = createServer((request, response) => {
  response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
  response.end('Hello from Node\n');
});

server.listen(3000, () => console.log('http://localhost:3000'));

Run node server.js and open the address. The callback runs once per request; request carries the method, URL and headers, and you write the status, headers and body to response. end() sends the body and finishes. The node: prefix marks a built-in module, as opposed to one from node_modules.

Routing by hand

A server for a real page needs to do different things for different URLs. Parse the URL once, then branch:

import { createServer } from 'node:http';
import { readFile, stat } from 'node:fs/promises';
import { join, extname, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';

const PUBLIC = fileURLToPath(new URL('./public/', import.meta.url));
const TYPES = {
  '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
  '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml',
};
const tasks = [{ id: 1, text: 'Write a server', done: false }];

const server = createServer(async (req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`);

  if (url.pathname === '/api/tasks' && req.method === 'GET') {
    return sendJSON(res, 200, tasks);
  }
  if (url.pathname === '/api/tasks' && req.method === 'POST') {
    const body = await readJSON(req);
    if (!body || typeof body.text !== 'string' || !body.text.trim()) {
      return sendJSON(res, 400, { error: 'text is required' });
    }
    const task = { id: Date.now(), text: body.text.trim(), done: false };
    tasks.push(task);
    return sendJSON(res, 201, task);
  }
  return sendFile(res, url.pathname);
});

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

async function readJSON(req) {
  let text = '';
  for await (const chunk of req) {
    text += chunk;
    if (text.length > 1e6) return null;     // refuse bodies over ~1 MB
  }
  try { return JSON.parse(text); } catch { return null; }
}

async function sendFile(res, pathname) {
  // Resolve inside PUBLIC only: "normalize" collapses "../" so a request
  // for /../server.js cannot climb out of the folder.
  const safe = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '');
  let file = join(PUBLIC, safe);
  try {
    if ((await stat(file)).isDirectory()) file = join(file, 'index.html');
    const data = await readFile(file);
    res.writeHead(200, { 'Content-Type': TYPES[extname(file)] ?? 'application/octet-stream' });
    res.end(data);
  } catch {
    res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
    res.end('Not found\n');
  }
}

server.listen(3000, () => console.log('http://localhost:3000'));

Put an index.html in a public folder next to server.js and start it. / serves the file, /api/tasks returns the array, and a POST adds to it. Try the POST from the browser console:

await (await fetch('/api/tasks', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ text: 'Test' }),
})).json();

Three things in the file deserve attention. The request body arrives in chunks, which is why readJSON loops with for await; the size cap stops a client from filling memory. The path check in sendFile is not optional: without it, /../server.js or an encoded equivalent would read your source code, and on a real server, anything else on the disk. And the content type table matters because browsers refuse to run a script served as text/plain and will not apply a stylesheet with the wrong type.

What a framework adds

Everything above is about 60 lines, and a framework such as Express, Fastify or Hono replaces most of it: a router that matches app.get('/api/tasks/:id', handler) with parameters parsed, body parsing with limits, a static file handler with caching headers and the path check done properly, and middleware for logging and errors. Once you know what the framework is doing, use one; you will not write a safer static handler than the maintained one. The same Express route for comparison:

import express from 'express';
const app = express();
app.use(express.json({ limit: '1mb' }));
app.use(express.static('public'));
app.get('/api/tasks', (req, res) => res.json(tasks));
app.listen(3000);

Keeping it running

  • During development, node --watch server.js (Node 18.11+) restarts on file changes.
  • Unhandled errors crash the process, which is correct: a process in an unknown state should not keep serving. In production something must restart it: a systemd unit on Linux, or a process manager such as pm2.
  • Configuration comes from environment variables, not files you commit: const PORT = process.env.PORT ?? 3000.
  • Put a reverse proxy such as Caddy or nginx in front for HTTPS, compression and serving static files; let Node do only the dynamic part.

Pitfalls

  • Forgetting to end(). The browser spins forever. Every branch must send a response.
  • Blocking the event loop. Node, like the browser, is single-threaded. readFileSync in a request handler stalls every other request while the disk is read; use the promise versions.
  • State in memory. The tasks array disappears when the process restarts and is not shared between processes. It is fine for learning; real data goes in a database or at least a file.
  • Trusting req.headers.host and the URL. Both come from the client. Validate anything you use for file paths or redirects.
  • Port in use. EADDRINUSE means a previous instance is still running; find it with lsof -i :3000 (macOS/Linux) and stop it.

Exercise

Add PATCH /api/tasks/:id that toggles done, and DELETE /api/tasks/:id. Parse the id from the path with a regular expression or by splitting on /; return 404 for an unknown id and 400 for a non-numeric one. Then connect the to-do app from the DOM tutorial to it by replacing the localStorage functions with fetch calls.

Summary

  • node file.js runs a script; a listening server keeps it alive.
  • createServer gives you request and response objects; parse the URL, branch, always respond.
  • Read bodies in chunks with a size cap; serve files only from inside one folder, with the right content type.
  • Frameworks package these patterns; use one once you understand them.