TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

Build a REST API with Node.js: From Empty Folder to Deployment

Practical insights, clear explanations, and useful ideas for understanding the web.

October 25, 202610 min read6 topics
Build a REST API with Node.js: From Empty Folder to Deployment
Explore the details
#Node.js#REST API#JavaScript#Backend Development#API Testing#Web Development
October 25, 202610 min read

Building a REST API with Node.js is a practical way to learn how HTTP requests, routes, validation, and error handling fit together. In this tutorial, you’ll create a small task API using Node.js built-in modules, add automated tests, and prepare the app to run on a hosting platform. The example keeps data in memory so you can focus on the API; it is not a production database or a production-ready service.

Key takeaways

  • Create an HTTP server with Node.js’s built-in http module—no framework required.
  • Implement CRUD routes for tasks and validate incoming JSON.
  • Return consistent JSON errors and limit request-body size.
  • Configure the listening port through an environment variable.
  • Test the API with Node’s built-in test runner and prepare it for deployment.

1. Define the API before writing code

We’ll build a task API with this contract:

MethodPathPurposeSuccess response
GET/api/tasksList tasks200 and an array
POST/api/tasksCreate a task201 and the task
GET/api/tasks/:idRead one task200 and the task
PATCH/api/tasks/:idUpdate task fields200 and the task
DELETE/api/tasks/:idDelete a task204 with no body
GET/healthCheck whether the process responds200 and a status object

A task has an id, a non-empty title of up to 120 characters, and a Boolean completed value. The client may omit completed when creating a task; it defaults to false.

The HTTP status codes are part of the API contract. For example, 201 indicates a resource was created, 404 indicates the requested task does not exist, and 400 indicates invalid input.

2. Create the project structure

Use Node.js 20 or later for this example. Create these files:

node-tasks-api/
├── package.json
├── .gitignore
├── src/
│   ├── app.js
│   └── server.js
└── test/
    └── app.test.js

Add the package metadata and scripts to package.json:

{
  "name": "node-tasks-api",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "test": "node --test"
  },
  "engines": {
    "node": ">=20"
  }
}

The type field enables ES modules. This project uses only Node.js built-ins, so it does not need a dependency installation step. Add a .gitignore file so local environment files and installed packages aren’t committed:

node_modules/
.env

3. Implement routes, validation, and errors

Put the request handler in src/app.js. Keeping it separate from the process startup code makes it easier to test the API without binding to a fixed port.

import { randomUUID } from 'node:crypto';

const MAX_BODY_BYTES = 1_000_000;

class HttpError extends Error {
  constructor(status, message) {
    super(message);
    this.status = status;
  }
}

function sendJson(res, status, data, extraHeaders = {}) {
  const body = JSON.stringify(data);
  res.writeHead(status, {
    'content-type': 'application/json; charset=utf-8',
    'content-length': Buffer.byteLength(body),
    'x-content-type-options': 'nosniff',
    ...extraHeaders,
  });
  res.end(body);
}

function sendNoContent(res) {
  res.writeHead(204);
  res.end();
}

async function readJson(req) {
  const contentType = req.headers['content-type'] ?? '';
  if (!contentType.toLowerCase().startsWith('application/json')) {
    throw new HttpError(415, 'Content-Type must be application/json');
  }

  const chunks = [];
  let size = 0;

  for await (const chunk of req) {
    size += chunk.length;
    if (size > MAX_BODY_BYTES) {
      req.resume();
      throw new HttpError(413, 'Request body is too large');
    }
    chunks.push(chunk);
  }

  let value;
  try {
    value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
  } catch {
    throw new HttpError(400, 'Request body must be valid JSON');
  }

  if (value === null || Array.isArray(value) || typeof value !== 'object') {
    throw new HttpError(400, 'Request body must be a JSON object');
  }

  return value;
}

function validateTask(input, { partial = false } = {}) {
  const allowedFields = new Set(['title', 'completed']);
  for (const key of Object.keys(input)) {
    if (!allowedFields.has(key)) {
      throw new HttpError(400, `Unknown field: ${key}`);
    }
  }

  const result = {};

  if (Object.hasOwn(input, 'title')) {
    if (typeof input.title !== 'string') {
      throw new HttpError(400, 'title must be a string');
    }
    const title = input.title.trim();
    if (title.length === 0 || title.length > 120) {
      throw new HttpError(400, 'title must contain 1 to 120 characters');
    }
    result.title = title;
  }

  if (Object.hasOwn(input, 'completed')) {
    if (typeof input.completed !== 'boolean') {
      throw new HttpError(400, 'completed must be a Boolean');
    }
    result.completed = input.completed;
  }

  if (!partial && !Object.hasOwn(result, 'title')) {
    throw new HttpError(400, 'title is required');
  }

  if (partial && Object.keys(result).length === 0) {
    throw new HttpError(400, 'Provide at least one field to update');
  }

  return result;
}

function methodNotAllowed(res, methods) {
  sendJson(res, 405, { error: 'Method not allowed' }, {
    allow: methods.join(', '),
  });
}

export function createHandler() {
  // This Map is deliberately temporary storage for the tutorial.
  const tasks = new Map();

  return async function handleRequest(req, res) {
    try {
      const url = new URL(req.url ?? '/', 'http://localhost');
      const parts = url.pathname.split('/').filter(Boolean);

      if (url.pathname === '/health') {
        if (req.method !== 'GET') {
          return methodNotAllowed(res, ['GET']);
        }
        return sendJson(res, 200, { status: 'ok' });
      }

      if (parts[0] !== 'api' || parts[1] !== 'tasks') {
        throw new HttpError(404, 'Route not found');
      }

      if (parts.length === 2) {
        if (req.method === 'GET') {
          return sendJson(res, 200, [...tasks.values()]);
        }

        if (req.method === 'POST') {
          const input = validateTask(await readJson(req));
          const task = {
            id: randomUUID(),
            title: input.title,
            completed: input.completed ?? false,
          };
          tasks.set(task.id, task);
          return sendJson(res, 201, task, {
            location: `/api/tasks/${task.id}`,
          });
        }

        return methodNotAllowed(res, ['GET', 'POST']);
      }

      if (parts.length === 3) {
        const id = parts[2];
        const existing = tasks.get(id);

        if (req.method === 'GET') {
          if (!existing) throw new HttpError(404, 'Task not found');
          return sendJson(res, 200, existing);
        }

        if (req.method === 'PATCH') {
          if (!existing) throw new HttpError(404, 'Task not found');
          const changes = validateTask(await readJson(req), { partial: true });
          const updated = { ...existing, ...changes };
          tasks.set(id, updated);
          return sendJson(res, 200, updated);
        }

        if (req.method === 'DELETE') {
          if (!existing) throw new HttpError(404, 'Task not found');
          tasks.delete(id);
          return sendNoContent(res);
        }

        return methodNotAllowed(res, ['GET', 'PATCH', 'DELETE']);
      }

      throw new HttpError(404, 'Route not found');
    } catch (error) {
      if (res.headersSent) {
        res.destroy();
        return;
      }

      const status = error instanceof HttpError ? error.status : 500;
      const message = error instanceof HttpError
        ? error.message
        : 'Internal server error';
      sendJson(res, status, { error: message });
    }
  };
}

The handler uses a Map to keep the example self-contained. That means data disappears when the process restarts, and separate application instances will not share tasks. Replace it with a persistent database before relying on the API for real users or deploying multiple instances.

The readJson helper checks the media type, limits the body size, and turns malformed JSON into a client error. The validator accepts only known fields and checks their types and lengths. These checks make the API’s expectations explicit; they do not replace business-specific validation or database constraints.

4. Start the HTTP server with an environment variable

Create src/server.js to connect the handler to Node’s HTTP server. The hosting platform can provide PORT; the local default is 3000.

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

const port = Number(process.env.PORT ?? 3000);
const host = process.env.HOST ?? '0.0.0.0';

if (!Number.isInteger(port) || port < 1 || port > 65535) {
  throw new Error('PORT must be an integer between 1 and 65535');
}

const server = createServer(createHandler());

server.listen(port, host, () => {
  console.log(`API listening on ${host}:${port}`);
});

server.on('error', (error) => {
  console.error('HTTP server failed:', error);
  process.exitCode = 1;
});

Environment variables are available through process.env. This code does not load a .env file automatically; for local work, set variables in your shell or use an environment-file tool you trust. Never commit real secrets to source control. This sample has no secrets, but production services commonly use environment variables or a hosting platform’s secret manager for sensitive configuration.

Start the server:

npm start

In another terminal, create a task with curl:

curl -i -X POST http://localhost:3000/api/tasks \
  -H 'Content-Type: application/json' \
  -d '{"title":"Write API tests"}'

The response should have status 201, a JSON task with a generated ID, and a Location header. Then list tasks:

curl http://localhost:3000/api/tasks

When an endpoint behaves unexpectedly, check the HTTP method, path, content type, and request body. You can also use TidyURLs tools when you need help inspecting or cleaning a URL before testing it; a URL utility does not validate the API’s server-side input.

5. Add automated tests

Node’s built-in test runner can start the handler on an available local port and make real HTTP requests. Add test/app.test.js:

import test, { before, after } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { createHandler } from '../src/app.js';

const server = createServer(createHandler());
let baseUrl;

before(async () => {
  server.listen(0, '127.0.0.1');
  await once(server, 'listening');
  const address = server.address();
  baseUrl = `http://127.0.0.1:${address.port}`;
});

after(async () => {
  server.close();
  await once(server, 'close');
});

test('creates a task and retrieves it', async () => {
  const createResponse = await fetch(`${baseUrl}/api/tasks`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ title: 'Write tests' }),
  });

  assert.equal(createResponse.status, 201);
  const created = await createResponse.json();
  assert.equal(created.title, 'Write tests');
  assert.equal(created.completed, false);
  assert.ok(created.id);

  const getResponse = await fetch(`${baseUrl}/api/tasks/${created.id}`);
  assert.equal(getResponse.status, 200);
  assert.deepEqual(await getResponse.json(), created);
});

test('rejects an invalid task title', async () => {
  const response = await fetch(`${baseUrl}/api/tasks`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ title: '   ' }),
  });

  assert.equal(response.status, 400);
  assert.deepEqual(await response.json(), {
    error: 'title must contain 1 to 120 characters',
  });
});

Run the tests with:

npm test

These tests exercise the HTTP boundary instead of calling internal functions directly. Add cases for malformed JSON, unsupported content types, unknown routes, missing IDs, updates, deletion, and oversized bodies as the API grows. Tests here use a fresh in-memory store for the test server, but they do not test persistence or production infrastructure.

6. Prepare the API for deployment

The exact deployment steps depend on the hosting provider, but the application is designed to use the standard Node process model:

  1. Commit the source files and package.json to your repository. Do not commit .env files containing secrets.
  2. Configure the hosting service to use a compatible Node.js version, install dependencies if required by its build process, and run npm start.
  3. Let the platform provide PORT. The server binds to 0.0.0.0, which allows it to accept connections through the platform’s network setup.
  4. Configure the platform’s health check to request GET /health, if it supports health checks.
  5. Review logs and test the deployed API’s health and CRUD routes using the deployment’s assigned HTTPS address.

The health route only confirms that the process can respond. It does not confirm that a database, queue, or other dependency is healthy. For a real service, choose an appropriate health-check design and avoid exposing sensitive operational details in its response.

7. Common mistakes and production gaps

Treating in-memory data as persistent

A Map is useful for learning and isolated tests, but it loses data on restart and is not shared between server instances. Use a persistent database with a considered schema, backups, migrations, and appropriate access controls for production.

Returning success for invalid input

Checking only whether a field exists is not enough. Validate types, lengths, allowed fields, and any domain rules the application requires. Keep validation at the API boundary, and enforce important invariants in the database as well where possible.

Exposing internal exceptions

Unexpected errors should not be returned to clients with stack traces or implementation details. This example returns a generic 500 message for unrecognized errors. In a real service, log diagnostic details securely on the server and use request IDs to connect logs to client reports.

Assuming a tutorial server is a complete security design

This small API does not implement authentication, authorization, rate limiting, CORS policy, request logging, graceful shutdown, or database protection. Add controls appropriate to the service and its deployment environment. If the API later fetches URLs or accesses network resources based on user input, treat that as a separate security boundary; parsing a URL alone is not a complete defense against server-side request forgery.

Ignoring HTTP behavior

Use the right status code, return a consistent response format, and document which methods each route supports. The sample sets an Allow header for unsupported methods and returns no response body for successful deletion. Clients and tests can rely on these details.

Conclusion

You now have a small REST API built with Node.js’s HTTP server, explicit routes, JSON validation, consistent errors, environment-based configuration, and automated HTTP tests. The example is a learning foundation, not a finished production service: replace its in-memory store and add the security, observability, and operational controls your application needs before relying on it.

References