React makes it easy to build interactive user interfaces, but most production applications need more than components and client-side state. They need reliable APIs to fetch data, authenticate users, validate input, handle errors, and communicate with databases securely.
A well-designed API creates a clear contract between your React frontend and backend services. Instead of mixing business logic into components, you can keep the frontend focused on presentation while Node.js and Express handle requests, validation, and application logic.
In this guide, we'll build a practical REST API using JavaScript, Node.js, and Express that a React application can consume. We'll cover API design, request validation, centralized error handling, pagination, and frontend integration using the native Fetch API.
Designing a REST API That Works Well with React
A React application typically communicates with a backend through HTTP requests. For example, a dashboard might request a list of projects, submit a new task, or update a user's profile. REST APIs organize these operations around resources and standard HTTP methods, making the contract straightforward for frontend developers to understand.
A useful starting point is to define predictable endpoints. Use GET /api/tasks to retrieve tasks, GET /api/tasks/:id to retrieve one task, POST /api/tasks to create a task, and PATCH /api/tasks/:id to update selected fields. DELETE /api/tasks/:id can remove a task. Return appropriate HTTP status codes, use consistent JSON response structures, and avoid exposing internal database details to the client.
Validation should happen on the server, even when the React interface already validates user input. Client-side validation improves the user experience, but API requests can originate from scripts, browser tools, or other clients. The backend must independently verify required fields, enforce constraints, and reject malformed requests before processing them.
Another important design decision is error handling. Returning consistent error objects allows React components to display useful messages without needing to understand backend implementation details. In production, avoid returning stack traces, database credentials, or other sensitive information. Authentication, authorization, rate limiting, and database access should be added according to the application's security requirements.
The following example implements a small task-management API using an in-memory array, making it runnable without a database. It includes request validation, resource lookup, pagination, CRUD operations, and centralized error handling. The same endpoint structure can later be connected to MongoDB, PostgreSQL, or another persistent storage layer without requiring a complete redesign of the React interface.
const express = require("express");
const cors = require("cors");
// Create the Express application and configure its middleware.
const app = express();
const PORT = process.env.PORT || 4000;
// Allow requests from the local React development server.
// Restrict this origin to your deployed frontend in production.
app.use(cors({ origin: process.env.CLIENT_ORIGIN || "http://localhost:3000" }));
app.use(express.json({ limit: "10kb" }));
// In-memory storage keeps this example self-contained.
// Data will reset whenever the server restarts.
let nextTaskId = 3;
let tasks = [
{ id: 1, title: "Design dashboard", completed: false },
{ id: 2, title: "Build React components", completed: true },
];
// Standardize API errors so the frontend can handle them consistently.
class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
// Validate task titles before creating or updating resources.
function validateTitle(title) {
if (typeof title !== "string" || title.trim().length === 0) {
throw new ApiError(400, "A non-empty title is required.");
}
if (title.trim().length > 120) {
throw new ApiError(400, "Title must be 120 characters or fewer.");
}
return title.trim();
}
// A health endpoint helps developers verify that the server is running.
app.get("/api/health", (req, res) => {
console.log("Step 1: Health check requested");
res.status(200).json({ status: "ok", service: "task-api" });
});
// Return paginated tasks instead of requiring the frontend to load everything.
app.get("/api/tasks", (req, res) => {
console.log("Step 2: Fetching tasks");
const page = Math.max(1, Number.parseInt(req.query.page, 10) || 1);
const limit = Math.min(50, Math.max(1, Number.parseInt(req.query.limit, 10) || 10));
const startIndex = (page - 1) * limit;
const paginatedTasks = tasks.slice(startIndex, startIndex + limit);
res.status(200).json({
data: paginatedTasks,
pagination: {
page,
limit,
total: tasks.length,
totalPages: Math.ceil(tasks.length / limit),
},
});
});
// Retrieve one task and return a clear 404 response when it does not exist.
app.get("/api/tasks/:id", (req, res, next) => {
console.log("Step 3: Looking up task", req.params.id);
const id = Number(req.params.id);
const task = tasks.find((item) => item.id === id);
if (!Number.isInteger(id) || !task) {
return next(new ApiError(404, "Task not found."));
}
res.status(200).json({ data: task });
});
// Create a task only after validating the request body.
app.post("/api/tasks", (req, res, next) => {
console.log("Step 4: Creating a task");
try {
const title = validateTitle(req.body.title);
const task = { id: nextTaskId++, title, completed: false };
tasks.push(task);
console.log("Task created successfully:", task.id);
res.status(201).json({ data: task });
} catch (error) {
next(error);
}
});
// Update only the fields this endpoint explicitly supports.
app.patch("/api/tasks/:id", (req, res, next) => {
console.log("Step 5: Updating a task");
const id = Number(req.params.id);
const task = tasks.find((item) => item.id === id);
if (!Number.isInteger(id) || !task) {
return next(new ApiError(404, "Task not found."));
}
try {
if (Object.hasOwn(req.body, "title")) {
task.title = validateTitle(req.body.title);
}
if (Object.hasOwn(req.body, "completed")) {
if (typeof req.body.completed !== "boolean") {
throw new ApiError(400, "Completed must be a boolean.");
}
task.completed = req.body.completed;
}
res.status(200).json({ data: task });
} catch (error) {
next(error);
}
});
// Delete a task and return 204 when the operation succeeds.
app.delete("/api/tasks/:id", (req, res, next) => {
console.log("Step 6: Deleting a task");
const id = Number(req.params.id);
const index = tasks.findIndex((item) => item.id === id);
if (!Number.isInteger(id) || index === -1) {
return next(new ApiError(404, "Task not found."));
}
tasks.splice(index, 1);
res.status(204).end();
});
// Convert malformed JSON and application errors into consistent responses.
app.use((error, req, res, next) => {
if (res.headersSent) {
return next(error);
}
const status = error instanceof ApiError ? error.status : 500;
const message = status === 500 ? "Internal server error." : error.message;
if (status === 500) {
console.error("Unexpected API error:", error.message);
}
res.status(status).json({ error: { message } });
});
// Handle unknown endpoints with a predictable JSON response.
app.use((req, res) => {
res.status(404).json({ error: { message: "Endpoint not found." } });
});
// Start the API server and print useful development instructions.
app.listen(PORT, () => {
console.log(`Task API listening at http://localhost:${PORT}`);
console.log("Try GET /api/health or GET /api/tasks?page=1&limit=10");
});
// Install dependencies with: npm install express cors
// Start the server with: node server.js
// Example React integration using the native Fetch API:
async function fetchTasks(page = 1, limit = 10) {
const response = await fetch(
`http://localhost:4000/api/tasks?page=${page}&limit=${limit}`
);
// Fetch resolves even for HTTP errors, so check response.ok explicitly.
if (!response.ok) {
const body = await response.json();
throw new Error(body.error?.message || "Unable to fetch tasks.");
}
const result = await response.json();
console.log("Step 7: Tasks received by the React client", result.data);
return result;
}
// Run fetchTasks() from a React event handler or effect in your frontend.
// For example: fetchTasks().then(console.log).catch(console.error);
Conclusion
Building APIs for React applications becomes much easier when the frontend and backend have clearly defined responsibilities. React manages the interface and user interactions, while the API validates requests, enforces business rules, and returns predictable responses. Consistent status codes and error structures also make frontend error handling significantly simpler.
The example provides a foundation for a task-management application, but its in-memory storage is intended only for development and demonstration. For production, add a database, authentication, authorization, request rate limiting, structured logging, automated tests, and environment-specific CORS settings. Validate pagination parameters carefully and ensure that every protected resource checks the requesting user's permissions.
Start with a small, consistent API contract, document the endpoints, and test them independently before connecting your React components. As your application grows, these practices make it easier to maintain the codebase, introduce new features, and support multiple clients without duplicating business logic.
Top comments (0)