DEV Community

Ansh Sheladiya
Ansh Sheladiya

Posted on

Backend Development with Node.js: Build Scalable, Secure APIs

Backend development is the foundation of modern web applications. It handles business logic, database operations, authentication, API requests, and communication between different services. While frontend technologies focus on what users see, the backend ensures everything works reliably behind the scenes.

Node.js has become a popular choice for backend development because it allows developers to use JavaScript on both the frontend and backend. Its event-driven, non-blocking I/O architecture makes it particularly useful for applications that handle numerous concurrent requests, such as SaaS platforms, e-commerce websites, and real-time applications.

In this article, we will explore how Node.js works, how to structure backend applications, and how to build a practical REST API using native Node.js modules. You will also learn important techniques for request validation, error handling, and writing maintainable backend code.

Building a REST API with Node.js: From Request to Response

Node.js runs JavaScript outside the browser using the V8 JavaScript engine. Instead of creating a separate thread for every incoming request, it uses an event loop and asynchronous I/O to coordinate many operations efficiently. This architecture helps applications remain responsive while waiting for network requests, database queries, or file operations to complete.

A well-designed backend usually separates responsibilities into clear layers. Routes determine which endpoint handles a request, controllers coordinate application behavior, services contain business rules, and repositories or data-access modules communicate with the database. For a small example, we can keep everything in one file while maintaining clear separation between request handling, validation, and response generation.

The following example uses Node.js's built-in HTTP module to implement a simple task management API. It supports listing tasks, retrieving an individual task, creating a task, updating an existing task, and deleting a task. The example stores data in memory, so it is suitable for learning and local experimentation rather than production persistence.

To run the application, save the code as server.js and execute node server.js. The server listens on port 3000 by default, and you can test its endpoints using curl, Postman, or another HTTP client. For production applications, extend this foundation with persistent database storage, authentication, authorization, rate limiting, request logging, and automated tests.

const http = require('node:http');
const { randomUUID } = require('node:crypto');

// Store example tasks in memory for this learning project.
const tasks = [
  { id: randomUUID(), title: 'Learn Node.js', completed: false },
  { id: randomUUID(), title: 'Build a REST API', completed: true },
];

const PORT = Number(process.env.PORT) || 3000;

// Send a consistent JSON response with the appropriate HTTP status.
function sendJson(res, statusCode, data) {
  res.writeHead(statusCode, {
    'Content-Type': 'application/json; charset=utf-8',
    'Cache-Control': 'no-store',
  });
  res.end(JSON.stringify(data));
}

// Read and validate the incoming JSON request body.
async function readJsonBody(req) {
  const chunks = [];
  let size = 0;
  const maxBodySize = 1024 * 1024;

  for await (const chunk of req) {
    size += chunk.length;
    if (size > maxBodySize) {
      const error = new Error('Request body is too large');
      error.statusCode = 413;
      throw error;
    }
    chunks.push(chunk);
  }

  try {
    return JSON.parse(Buffer.concat(chunks).toString('utf8'));
  } catch {
    const error = new Error('Request body must contain valid JSON');
    error.statusCode = 400;
    throw error;
  }
}

// Only accept non-empty task titles within a reasonable length.
function validateTaskTitle(title) {
  return typeof title === 'string' &&
    title.trim().length > 0 &&
    title.trim().length <= 120;
}

const server = http.createServer(async (req, res) => {
  const startedAt = Date.now();
  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
  const pathname = url.pathname;
  const method = req.method;

  console.log(`--> ${method} ${pathname}`);

  try {
    // GET /health provides a lightweight health-check endpoint.
    if (method === 'GET' && pathname === '/health') {
      return sendJson(res, 200, { status: 'ok' });
    }

    // GET /tasks returns the current collection.
    if (method === 'GET' && pathname === '/tasks') {
      return sendJson(res, 200, { data: tasks, total: tasks.length });
    }

    // Match routes containing a task identifier.
    const taskMatch = pathname.match(/^\/tasks\/([^/]+)$/);

    if (taskMatch) {
      const taskId = taskMatch[1];
      const taskIndex = tasks.findIndex((task) => task.id === taskId);

      // Return a clear 404 response for unknown task identifiers.
      if (taskIndex === -1) {
        return sendJson(res, 404, { error: 'Task not found' });
      }

      if (method === 'GET') {
        return sendJson(res, 200, { data: tasks[taskIndex] });
      }

      if (method === 'PATCH') {
        const body = await readJsonBody(req);
        const currentTask = tasks[taskIndex];

        // Update only explicitly supported fields.
        if (Object.hasOwn(body, 'title')) {
          if (!validateTaskTitle(body.title)) {
            return sendJson(res, 400, {
              error: 'Title must contain 1 to 120 characters',
            });
          }
          currentTask.title = body.title.trim();
        }

        if (Object.hasOwn(body, 'completed')) {
          if (typeof body.completed !== 'boolean') {
            return sendJson(res, 400, {
              error: 'Completed must be a boolean',
            });
          }
          currentTask.completed = body.completed;
        }

        return sendJson(res, 200, { data: currentTask });
      }

      if (method === 'DELETE') {
        const [deletedTask] = tasks.splice(taskIndex, 1);
        return sendJson(res, 200, {
          message: 'Task deleted successfully',
          data: deletedTask,
        });
      }

      res.setHeader('Allow', 'GET, PATCH, DELETE');
      return sendJson(res, 405, { error: 'Method not allowed' });
    }

    // POST /tasks creates a new task after validating its input.
    if (method === 'POST' && pathname === '/tasks') {
      const body = await readJsonBody(req);

      if (!validateTaskTitle(body.title)) {
        return sendJson(res, 400, {
          error: 'Title must contain 1 to 120 characters',
        });
      }

      const newTask = {
        id: randomUUID(),
        title: body.title.trim(),
        completed: false,
      };

      tasks.push(newTask);
      console.log(`Created task: ${newTask.id}`);
      return sendJson(res, 201, { data: newTask });
    }

    // All unsupported routes receive a predictable JSON response.
    return sendJson(res, 404, { error: 'Route not found' });
  } catch (error) {
    // Avoid exposing internal implementation details to API clients.
    console.error('Request failed:', error.message);
    if (!res.headersSent) {
      return sendJson(res, error.statusCode || 500, {
        error: error.statusCode ? error.message : 'Internal server error',
      });
    }
    res.destroy();
  } finally {
    console.log(`<-- ${method} ${pathname} (${Date.now() - startedAt}ms)`);
  }
});

// Log server startup and make the service easy to run locally.
server.listen(PORT, () => {
  console.log(`Task API running at http://localhost:${PORT}`);
  console.log('Available routes: GET /health, GET /tasks, POST /tasks');
  console.log('Task routes: GET /tasks/:id, PATCH /tasks/:id, DELETE /tasks/:id');
});

// Handle shutdown signals gracefully during local development.
function shutdown() {
  console.log('Shutting down the HTTP server...');
  server.close((error) => {
    if (error) {
      console.error('Shutdown failed:', error);
      process.exitCode = 1;
    }
  });
}

process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
Enter fullscreen mode Exit fullscreen mode

Conclusion

Node.js provides a flexible foundation for building backend services with JavaScript. By understanding its asynchronous execution model, HTTP request lifecycle, and error-handling patterns, developers can build APIs that are easier to maintain and extend as application requirements grow.

However, a working API is only the starting point. Production systems also need database transactions, authentication, authorization, structured logging, environment-based configuration, security headers, automated testing, and monitoring. Frameworks such as Express and Fastify can simplify routing and middleware, while NestJS provides a more structured architecture for larger applications.

The best way to improve your backend development skills is to build projects that solve real problems. Start with a small REST API, add a database such as PostgreSQL or MongoDB, introduce authentication, and then test how your application behaves under concurrent requests and unexpected failures. Which Node.js backend feature would you implement next?

Top comments (0)