Build a REST API with Node.js and Express: A Beginner's Guide

LearnWebCraft Team
18 min read
rest apinodejsexpressbackend development

Alright, let's talk about the backend. For the longest time, it felt like this mystical, dark art to me. My comfort zone was the frontend—playing with colors, layouts, and user interactions. But the backend? That felt like the engine room of a giant ship, a place full of servers and databases I wasn't sure I was ready to touch. If any of that sounds familiar, you're in exactly the right place. Today, we're going to pull back the curtain and demystify it all by building a REST API with Node.js and Express.

And I promise, it's not nearly as scary as it sounds. In fact, it's incredibly empowering.

By the end of this tutorial, you'll have a fully functional API that can create, read, update, and delete data. We're going to build it from the ground up, step-by-step. No magic, no "just copy-paste this" without a good reason. We'll make sure we understand what we're doing and, more importantly, why we're doing it.

Setting the Stage: Prerequisites & Setup

Okay, before we dive into a single line of API code, let's get our digital workspace ready. I always think of this part like prepping your ingredients before you start cooking—it just makes the whole process so much smoother.

What You'll Need

  1. Node.js and npm: Node.js is the JavaScript runtime that lets us run JS outside of the browser, on the server. npm is the Node Package Manager, which comes bundled right along with it. It’s how we’ll install awesome tools like Express. You can download them from the official Node.js website. A quick node -v and npm -v in your terminal will tell you if you're all set.
  2. A Code Editor: I'm a big Visual Studio Code fan myself, but you should absolutely use whatever you're comfortable with. Sublime Text, Atom, WebStorm—they all work great.
  3. A Tool for API Testing: We'll need a way to send requests to our API to see if it's working. I highly recommend Postman or Insomnia. They're user-friendly and built for this exact purpose. You could use cURL in the terminal, but honestly, a GUI tool is so much easier when you're starting out.

That's really it! No complex database setup or cloud accounts are needed for this one.

Initializing Our Project

Let’s create a new home for our project. Pop open your terminal and run these commands:

mkdir my-first-api
cd my-first-api
npm init -y

That npm init -y command is a neat little shortcut. It instantly creates a package.json file for us, which is basically the ID card for our project. It keeps track of our project's details, dependencies, and scripts. The -y flag just tells it, "yep, I'm cool with all the default questions."

Next up, let's install Express, the real star of our show today.

npm install express

This command downloads Express, tucks it into our node_modules folder, and lists it as a dependency in package.json. Express is a minimal and flexible Node.js web application framework that gives us a ton of robust features for web and mobile apps. It truly makes building servers and APIs an absolute breeze.

Our First Express Server: The "Hello World" Moment

Every developer's journey has that one magical moment—seeing your first "Hello, World!" on a brand new platform. For us, that's getting our server to actually run and send a response back.

Create a new file in your project directory and call it index.js. This is going to be our main entry point.

Inside index.js, go ahead and type or paste the following code:

// 1. Import Express
const express = require('express');

// 2. Create an Express application
const app = express();

// 3. Define the port
const PORT = 3000;

// 4. Create a basic route
// This handles GET requests to the root URL ('/')
app.get('/', (req, res) => {
  res.send('Hello, World! Our API is running! 🚀');
});

// 5. Start the server
app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

Let’s break this down real quick so it's not just a block of code:

  1. We require('express') to bring the Express library into our file so we can use it.
  2. We call express() to create an instance of our application. We'll attach all our routes and configurations to this app object.
  3. We define a PORT for our server to listen on. 3000 is a pretty common choice for local development.
  4. app.get('/', ...) is our very first route. It tells the server: "Hey, when you receive a GET request to the root path (/), I want you to run this function." The function then sends back a simple string as the response.
  5. app.listen(...) kicks off the whole process, starting the server and telling it to listen for incoming requests on the port we specified.

Now, for the moment of truth. Head back to your terminal and run:

node index.js

You should see the message Server is running on http://localhost:3000. Open your favorite web browser and navigate to that URL.

Boom! "Hello, World! Our API is running! 🚀"

Take a second to let that sink in. You just built and ran a web server. It might seem simple, but this is the fundamental building block for everything that comes next. I still remember my first time doing this—it felt like I'd just unlocked a new level in a video game.

Understanding REST API Concepts (The Quick & Dirty Version)

Alright, our server is officially up and running. But what actually makes it a "REST API"? REST stands for REpresentational State Transfer, which... yeah, that sounds pretty academic and, let's be real, a little boring.

So let's ditch the textbook definition and use a better analogy. Think of your API as a restaurant waiter.

  • You (the client/frontend): You want to order some food.
  • The Waiter (the API): You don't just walk into the kitchen yourself, right? You talk to the waiter.
  • The Kitchen (the server/database): This is where all the data and logic live, hidden away.

REST is just a set of common-sense rules for how you and the waiter should communicate.

  • Resources: These are the "nouns" of your API. In a to-do list app, a "task" is a resource. In a blog, a "post" or a "comment" is a resource. It's simply the thing you want to work with.
  • Endpoints (URLs): This is the specific address where a resource lives. For our tasks, it might be /api/tasks. To get a specific task, it could be /api/tasks/123.
  • HTTP Verbs (Methods): These are the "verbs" of your API. They tell the waiter what you want to do.
    • GET: "Waiter, please get me the menu." (Fetch data)
    • POST: "Waiter, I want to place a new order." (Create new data)
    • PUT / PATCH: "Waiter, I'd like to change my order." (Update existing data)
    • DELETE: "Waiter, please cancel my order." (Delete data)

So, a REST API is really just a server that follows these conventions to let clients perform CRUD (Create, Read, Update, Delete) operations on its resources. See? Simple as that.

Building the API Endpoints

Now for the fun part. Let's turn our little "Hello World" server into a proper task management API. To keep things simple for now, we're not going to mess with a real database just yet. We'll store our tasks in a simple array, right in memory.

And I know what you might be thinking—a global variable for our data? It's definitely not a great long-term solution, but trust me, it's perfect for learning because it lets us focus purely on the API logic without getting bogged down.

First, let's add our mock data and a crucial piece of middleware to our index.js file.

const express = require('express');
const app = express();
const PORT = 3000;

// Middleware to parse JSON bodies
// This is SUPER important for POST and PUT requests
app.use(express.json());

// Our in-memory "database"
let tasks = [
  { id: 1, title: 'Learn Node.js', completed: false },
  { id: 2, title: 'Build a REST API', completed: false },
  { id: 3, title: 'Deploy the API', completed: false },
];

app.get('/', (req, res) => {
  res.send('Welcome to the Task API!');
});

// ... our API routes will go here ...

app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

That app.use(express.json()) line is absolutely key. It's a built-in middleware from Express that automatically parses incoming JSON payloads. Without it, req.body in our POST and PUT handlers would just be undefined, and we'd be left scratching our heads.

Now, let's build out our CRUD endpoints.

1. GET All Tasks (Read)

First, we need an endpoint to fetch the entire list of tasks.

// GET /api/tasks - Get all tasks
app.get('/api/tasks', (req, res) => {
  res.json(tasks);
});

Go ahead and add this to your index.js. Now, restart your server (hit Ctrl+C in the terminal and then run node index.js again) and navigate to http://localhost:3000/api/tasks in your browser or Postman. You should see your array of tasks sent back as clean JSON. Easy, right?

2. GET a Single Task (Read)

But what if we only want one specific task? We need a way to tell the API which one. For that, we'll use a route parameter.

// GET /api/tasks/:id - Get a single task by ID
app.get('/api/tasks/:id', (req, res) => {
  const taskId = parseInt(req.params.id);
  const task = tasks.find(t => t.id === taskId);

  if (!task) {
    return res.status(404).json({ message: 'Task not found' });
  }

  res.json(task);
});

See that :id part? That's a placeholder. Express will capture whatever value is in that segment of the URL and put it in req.params.id. We have to use parseInt because URL parameters always come in as strings. Then we find the task. If it doesn't exist, we send back a 404 Not Found status—this is a super important part of building good, predictable APIs.

Go on, test it out! Try http://localhost:3000/api/tasks/2. Then try /api/tasks/99. Notice the different responses?

3. POST a New Task (Create)

Alright, time to add some new tasks. This is where we'll use the POST method.

// POST /api/tasks - Create a new task
app.post('/api/tasks', (req, res) => {
  const { title } = req.body;

  if (!title) {
    return res.status(400).json({ message: 'Title is required' });
  }

  const newTask = {
    id: tasks.length + 1, // Simple ID generation
    title,
    completed: false,
  };

  tasks.push(newTask);
  res.status(201).json(newTask);
});

Here's what's new and exciting here:

  • We're using app.post instead of app.get.
  • We're accessing req.body. This is the JSON data sent by the client, which is made available to us by that express.json() middleware we added earlier.
  • We're doing some basic validation. A task has to have a title, right?
  • We create a newTask object. Our ID generation is pretty naive, but it gets the job done for now.
  • We push the new task into our tasks array.
  • We respond with a 201 Created status code and send back the newly created task. This is great for the client, so they know the ID of the new item.

To test this one, you'll need Postman. Set the method to POST, the URL to http://localhost:3000/api/tasks, then click on the "Body" tab, select "raw" and "JSON", and enter something like this:

{
  "title": "Test with Postman"
}

Hit that "Send" button, and you should get your new task back with an ID of 4! Now if you do a GET request to /api/tasks again, you'll see it right there in the list.

4. PUT a Task (Update)

What if we want to change a task, like marking it as complete? That's the perfect job for the PUT method.

// PUT /api/tasks/:id - Update an existing task
app.put('/api/tasks/:id', (req, res) => {
  const taskId = parseInt(req.params.id);
  const task = tasks.find(t => t.id === taskId);

  if (!task) {
    return res.status(404).json({ message: 'Task not found' });
  }

  const { title, completed } = req.body;

  // Update properties if they are provided
  if (title !== undefined) {
    task.title = title;
  }
  if (completed !== undefined) {
    task.completed = completed;
  }

  res.json(task);
});

This logic is a nice mix of our GET-by-ID and POST logic. We find the task first. If it exists, we update its properties based on whatever data we get in the request body. In Postman, try sending a PUT request to http://localhost:3000/api/tasks/1 with this as the body:

{
  "completed": true,
  "title": "Learn Node.js and Express"
}

You'll see the updated task sent right back in the response.

5. DELETE a Task (Delete)

And finally, let's get rid of a task we no longer need.

// DELETE /api/tasks/:id - Delete a task
app.delete('/api/tasks/:id', (req, res) => {
  const taskId = parseInt(req.params.id);
  const taskIndex = tasks.findIndex(t => t.id === taskId);

  if (taskIndex === -1) {
    return res.status(404).json({ message: 'Task not found' });
  }

  tasks.splice(taskIndex, 1);
  res.status(204).send(); // 204 No Content
});

Here, we use findIndex to get the task's position in the array. If we find it, splice removes it. We then send back a 204 No Content status. This is the standard, conventional response for a successful deletion where there's nothing meaningful to send back.

Give it a shot! Send a DELETE request to http://localhost:3000/api/tasks/3. It should succeed. If you try to run it a second time, you'll correctly get a 404 error.

Making Our Code Better: Routes and Controllers

Okay, let's take a look at our index.js file. It's... getting a little crowded, right? And we only have one type of resource! Can you imagine what this would look like if we added users, projects, and comments? It'd become a total nightmare to manage.

Let's refactor our code into a more scalable, professional structure. This is a really big step toward writing code you'd be proud to show off.

  1. Create a new folder in your project called routes.
  2. Inside that routes folder, create a file named tasks.js.

We're going to move all of our task-related route logic into this new file. To do that, we'll use a handy feature of Express called express.Router.

routes/tasks.js

const express = require('express');
const router = express.Router();

// Our in-memory "database" - for now, we'll move it here
// In a real app, this would be a database connection module
let tasks = [
  { id: 1, title: 'Learn Node.js', completed: false },
  { id: 2, title: 'Build a REST API', completed: false },
  { id: 3, title: 'Deploy the API', completed: false },
];

// GET /api/tasks - Get all tasks
router.get('/', (req, res) => {
  res.json(tasks);
});

// GET /api/tasks/:id - Get a single task by ID
router.get('/:id', (req, res) => {
  const taskId = parseInt(req.params.id);
  const task = tasks.find(t => t.id === taskId);
  if (!task) return res.status(404).json({ message: 'Task not found' });
  res.json(task);
});

// POST /api/tasks - Create a new task
router.post('/', (req, res) => {
    // ... (paste the POST logic here) ...
    // Note: ID generation needs to be smarter in a real app
    const { title } = req.body;
    if (!title) return res.status(400).json({ message: 'Title is required' });
    const newTask = { id: tasks.length + 1, title, completed: false };
    tasks.push(newTask);
    res.status(201).json(newTask);
});

// PUT /api/tasks/:id - Update an existing task
router.put('/:id', (req, res) => {
    // ... (paste the PUT logic here) ...
    const taskId = parseInt(req.params.id);
    const task = tasks.find(t => t.id === taskId);
    if (!task) return res.status(404).json({ message: 'Task not found' });
    const { title, completed } = req.body;
    if (title !== undefined) task.title = title;
    if (completed !== undefined) task.completed = completed;
    res.json(task);
});

// DELETE /api/tasks/:id - Delete a task
router.delete('/:id', (req, res) => {
    // ... (paste the DELETE logic here) ...
    const taskId = parseInt(req.params.id);
    const taskIndex = tasks.findIndex(t => t.id === taskId);
    if (taskIndex === -1) return res.status(404).json({ message: 'Task not found' });
    tasks.splice(taskIndex, 1);
    res.status(204).send();
});


module.exports = router;

Notice a couple of things here: we replaced app.get with router.get, and so on. We also don't need the /api/tasks prefix in our paths anymore, because we're going to handle that in index.js. At the very end, we export the router so other files can use it.

Now, let's clean up our index.js file and tell it to use our new router.

index.js (The new and improved version)

const express = require('express');
const tasksRouter = require('./routes/tasks'); // Import the router

const app = express();
const PORT = 3000;

app.use(express.json());

app.get('/', (req, res) => {
  res.send('Welcome to the Task API! Use /api/tasks to see the tasks.');
});

// Mount the router
// Any request starting with /api/tasks will be handled by tasksRouter
app.use('/api/tasks', tasksRouter);

app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

Look how clean and readable that is! Our main file now just cares about setting up the server and middleware, and then it delegates the actual API logic to the appropriate router. If we wanted to add a users API, we'd just create a routes/users.js file and add one more line: app.use('/api/users', usersRouter).

What's Next? Beyond the Basics

And there you have it. We've built a solid foundation. You now have a working, organized REST API. Seriously, take a moment to appreciate that. But, as you've probably guessed, the journey doesn't end here. This is really just the springboard to much bigger and more exciting things.

Here are the logical next steps you might take:

  • Persistent Data: Our in-memory array is great for learning, but it gets wiped clean every time the server restarts. The natural next step is to connect to a real database like MongoDB (using a library like Mongoose) or PostgreSQL (with something like Sequelize or Prisma).
  • Error Handling: We have some basic error checks, but a real-world application needs centralized error-handling middleware to catch unexpected errors gracefully and prevent crashes.
  • Authentication & Authorization: Right now, anyone on the planet can add or delete tasks from our API. You'll eventually want to implement a system (like JSON Web Tokens - JWT) to protect your endpoints and manage user permissions.
  • Environment Variables: Hardcoding the port number and other settings directly in the code isn't ideal. Using a .env file to manage your configuration for different environments (development, production) is standard practice.

Don't feel like you have to tackle all of that at once. Savor this victory first. You've come a long way!

Frequently Asked Questions

What's the difference between Node.js and Express?

That is a super common and excellent question! The way I like to think about it is this: Node.js is the engine of a car. It provides the core power (the JavaScript runtime). Express is like the car's chassis, steering wheel, and pedals. It's a framework built on top of Node.js that gives you the tools and structure to build a web application much faster and more easily than you could with just the "engine."

Is Express still relevant in 2025?

Absolutely. While there are some fantastic newer frameworks out there like Fastify or Koa, and full-stack frameworks like Next.js have amazing built-in API routes, Express remains incredibly popular, well-documented, and has a massive ecosystem of middleware. It's still the de-facto standard for many companies and an essential tool to have in your developer toolkit.

Can I use this API with a React front-end?

Yes! This is exactly what it's designed for. Your React application would use the browser's fetch API or a library like axios to make HTTP requests to the endpoints we just built (GET /api/tasks, POST /api/tasks, etc.). The API is completely decoupled from the frontend, which means you can build your client with any technology you want.

Why wouldn't I just use a framework like Next.js or NestJS?

Those are fantastic, powerful tools! The main difference is that they are more "opinionated" and come with a lot more boilerplate and concepts to learn right from the start. Learning Node and Express first teaches you the fundamental principles of how servers, routing, and middleware work under the hood. It’s kind of like learning to drive a manual car before an automatic one—it gives you a much deeper understanding of the mechanics, which makes you a better driver (or developer) in the long run.undefined