DEV Community

Ansh Sheladiya
Ansh Sheladiya

Posted on

Building a MERN Stack Application: A Practical Guide with JavaScript

Building a full-stack application becomes much easier when your frontend, backend, and database work together using a consistent technology stack. The MERN stack combines MongoDB, Express.js, React, and Node.js to help developers build modern, scalable web applications using JavaScript across the entire application.

Whether you are creating a task manager, an admin dashboard, or a business application, understanding how these technologies communicate is essential. Instead of treating the frontend and backend as separate projects, you can design a complete system where React handles the user interface, Express manages API routes, Node.js executes server-side logic, and MongoDB stores application data.

In this guide, we will build a small task management API using the MERN stack's backend technologies. You will learn how to structure the application, connect MongoDB, implement CRUD operations, validate requests, and prepare the API for integration with a React frontend.

Building a MERN Stack Application Step by Step

A MERN application typically follows a layered architecture. React renders the user interface and sends HTTP requests, Express defines API endpoints, Node.js runs the server, and MongoDB persists the data. Keeping these responsibilities separate makes the application easier to test, maintain, and extend as requirements grow.

Start by creating a project directory and installing the backend dependencies with npm init -y followed by npm install express mongoose dotenv cors. Install npm install --save-dev nodemon if you want automatic server restarts during development. Create a server.js file and a .env file containing your MongoDB connection string and server port; never commit real credentials to version control.

For this example, we will implement a task API with endpoints to create, retrieve, update, and delete tasks. Mongoose provides a schema that defines the expected document structure, while Express middleware parses incoming JSON and handles errors. The example uses async functions and centralized error handling so database failures do not silently disappear.

After the API is working, a React frontend can call these endpoints using fetch or a library such as Axios. In a production application, you should also add authentication, authorization, request validation, pagination, structured logging, and automated tests. Keep environment-specific configuration outside your source code, and validate all incoming data on the server rather than relying exclusively on frontend validation.

require("dotenv").config();

const express = require("express");
const mongoose = require("mongoose");
const cors = require("cors");

// Initialize the Express application and shared middleware.
const app = express();
const PORT = process.env.PORT || 5000;
const MONGODB_URI = process.env.MONGODB_URI;

app.use(cors());
app.use(express.json());

// Define the shape and validation rules for task documents.
const taskSchema = new mongoose.Schema(
  {
    title: {
      type: String,
      required: [true, "Task title is required"],
      trim: true,
      minlength: [3, "Title must contain at least 3 characters"],
      maxlength: [120, "Title cannot exceed 120 characters"]
    },
    completed: {
      type: Boolean,
      default: false
    }
  },
  { timestamps: true }
);

const Task = mongoose.model("Task", taskSchema);

// Confirm that the API process is responding to requests.
app.get("/api/health", (req, res) => {
  console.log("Step 1: Health check requested");
  res.status(200).json({ status: "ok", message: "Task API is running" });
});

// Retrieve tasks, newest first, with a bounded page size.
app.get("/api/tasks", async (req, res, next) => {
  try {
    console.log("Step 2: Fetching tasks from MongoDB");
    const limit = Math.min(Number.parseInt(req.query.limit, 10) || 20, 100);
    const tasks = await Task.find().sort({ createdAt: -1 }).limit(limit);
    console.log(`Retrieved ${tasks.length} task(s)`);
    res.status(200).json({ count: tasks.length, data: tasks });
  } catch (error) {
    next(error);
  }
});

// Create a task after checking the request payload.
app.post("/api/tasks", async (req, res, next) => {
  try {
    console.log("Step 3: Validating new task payload");
    const { title } = req.body;

    if (typeof title !== "string" || title.trim().length < 3) {
      return res.status(400).json({
        message: "Title must be a string containing at least 3 characters"
      });
    }

    const task = await Task.create({ title: title.trim() });
    console.log(`Created task with ID: ${task._id}`);
    return res.status(201).json({ message: "Task created", data: task });
  } catch (error) {
    return next(error);
  }
});

// Update only the supported fields of an existing task.
app.patch("/api/tasks/:id", async (req, res, next) => {
  try {
    console.log(`Step 4: Updating task ${req.params.id}`);

    if (!mongoose.isValidObjectId(req.params.id)) {
      return res.status(400).json({ message: "Invalid task ID" });
    }

    const updates = {};
    if (typeof req.body.title === "string") {
      updates.title = req.body.title.trim();
    }
    if (typeof req.body.completed === "boolean") {
      updates.completed = req.body.completed;
    }

    if (Object.keys(updates).length === 0) {
      return res.status(400).json({ message: "No valid fields provided" });
    }

    const task = await Task.findByIdAndUpdate(req.params.id, updates, {
      new: true,
      runValidators: true
    });

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

    console.log("Task updated successfully");
    return res.status(200).json({ message: "Task updated", data: task });
  } catch (error) {
    return next(error);
  }
});

// Delete a task by its MongoDB document ID.
app.delete("/api/tasks/:id", async (req, res, next) => {
  try {
    console.log(`Step 5: Deleting task ${req.params.id}`);

    if (!mongoose.isValidObjectId(req.params.id)) {
      return res.status(400).json({ message: "Invalid task ID" });
    }

    const task = await Task.findByIdAndDelete(req.params.id);
    if (!task) {
      return res.status(404).json({ message: "Task not found" });
    }

    console.log("Task deleted successfully");
    return res.status(200).json({ message: "Task deleted" });
  } catch (error) {
    return next(error);
  }
});

// Handle unknown API routes without exposing internal details.
app.use((req, res) => {
  res.status(404).json({ message: "Route not found" });
});

// Convert known validation errors into readable client responses.
app.use((error, req, res, next) => {
  console.error("Request failed:", error.message);

  if (error.name === "ValidationError" || error.name === "CastError") {
    return res.status(400).json({ message: "Invalid request data" });
  }

  return res.status(500).json({ message: "Internal server error" });
});

// Connect to MongoDB before accepting incoming requests.
async function startServer() {
  try {
    if (!MONGODB_URI) {
      throw new Error("MONGODB_URI is missing from environment variables");
    }

    console.log("Step 6: Connecting to MongoDB...");
    await mongoose.connect(MONGODB_URI);
    console.log("MongoDB connected successfully");

    app.listen(PORT, () => {
      console.log(`Step 7: API server listening on port ${PORT}`);
      console.log(`Health endpoint: http://localhost:${PORT}/api/health`);
      console.log(`Tasks endpoint: http://localhost:${PORT}/api/tasks`);
    });
  } catch (error) {
    console.error("Server startup failed:", error.message);
    process.exitCode = 1;
  }
}

// Start the application only after defining routes and middleware.
startServer();
Enter fullscreen mode Exit fullscreen mode

Conclusion

Building a MERN stack application is not just about connecting four technologies; it is about giving each layer a clear responsibility. React handles presentation and user interaction, Express organizes HTTP endpoints, Node.js executes backend logic, and MongoDB stores persistent data. A well-structured API makes it easier to introduce new features without turning the entire codebase into a tightly coupled system.

The task API in this guide demonstrates the fundamentals of CRUD operations, schema validation, asynchronous database access, and centralized error handling. Before deploying it, configure production environment variables, restrict CORS to trusted origins, add authentication and authorization where needed, validate every accepted field, and create automated tests for both successful requests and failure cases.

Once the backend is stable, connect it to a React interface and introduce features such as loading states, error messages, pagination, and optimistic updates where appropriate. Start small, measure real application behavior, and improve the architecture as the requirements become clearer. What kind of MERN stack application are you planning to build first?

Top comments (0)