Working with REST APIs in JavaScript: Complete Guide
Learn how to talk to REST APIs from JavaScript. Understand HTTP methods, status codes, request and response patterns, authentication, and how to structure API calls in real applications.
Working with REST APIs in JavaScript means making HTTP requests to a set of URLs that let you create, read, update, and delete data on a server. The server responds with data, usually in JSON format.
In JavaScript, the Fetch API and Axios are the two main tools for making these requests. The patterns are the same regardless of which tool you use: pick the right HTTP method, send and receive JSON, check the status code, and handle errors.
Creating and reading a post follow the shape you will reuse for every resource: pick the method, send JSON if there is a body, and check the status before trusting the response.
async function createPost(data) {
const response = await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Failed to create post: ${response.status}`);
return response.json();
}
async function getPost(id) {
const response = await fetch(`/api/posts/${id}`);
if (!response.ok) throw new Error(`Post not found: ${response.status}`);
return response.json();
}Updating and deleting mirror the same shape, with one difference: a successful delete often has no response body at all, so checking for status 204 before calling .json() avoids a parse error on an empty response.
async function updatePost(id, data) {
const response = await fetch(`/api/posts/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Failed to update: ${response.status}`);
return response.json();
}
async function deletePost(id) {
const response = await fetch(`/api/posts/${id}`, { method: "DELETE" });
if (!response.ok) throw new Error(`Failed to delete: ${response.status}`);
return response.status === 204 ? null : response.json();
}These four functions cover every REST operation you will encounter. The patterns repeat: pick the method, serialize the body, check the status, return the parsed response.
HTTP methods and when to use each
| Method | Operation | Has body? | Idempotent? | Example |
|---|---|---|---|---|
| GET | Read a resource | No | Yes | GET /users/1 |
| POST | Create a resource | Yes | No | POST /users |
| PUT | Replace a resource | Yes | Yes | PUT /users/1 |
| PATCH | Partially update | Yes | Not guaranteed | PATCH /users/1 |
| DELETE | Remove a resource | Usually no | Yes | DELETE /users/1 |
A request is idempotent if making it multiple times has the same effect as making it once. GET, PUT, and DELETE are idempotent. POST is not: creating the same resource twice creates two resources.
Idempotency matters most when a request might get retried, whether by your own retry logic, a flaky connection resending a request, or a user double-clicking a submit button. A retried PUT just overwrites the same data again with no harm done. A retried POST, without extra protection like an idempotency key, can silently create duplicate orders or duplicate accounts.
Understanding HTTP status codes
The server communicates success or failure through the status code. Status codes fall into ranges: 200-299 means success and the response body is safe to use, 300-399 is a redirect that fetch follows automatically, 400-499 means your request was wrong in some way, and 500-599 means the server itself broke. The individual codes worth recognizing by number are these:
| Status | Name | Typical response |
|---|---|---|
| 200 | OK | The request succeeded |
| 201 | Created | A resource was created (POST) |
| 204 | No Content | Success with no response body (DELETE) |
| 400 | Bad Request | The request body is malformed |
| 401 | Unauthorized | Missing or invalid credentials |
| 403 | Forbidden | Valid credentials but no permission |
| 404 | Not Found | The resource does not exist |
| 422 | Unprocessable | Validation failed. Check the response body for field errors |
| 429 | Too Many Requests | Rate limited. Wait and retry |
| 500 | Internal Server Error | Generic server failure |
Always read the response body on error statuses. Many APIs include a message or errors field with details about what went wrong. A lookup function keeps the status-to-type mapping separate from the async work of reading the body.
function classifyStatus(status, body) {
if (status === 401) return { type: "auth", message: "Please log in again." };
if (status === 422) return { type: "validation", message: body.message, fields: body.errors };
if (status === 429) return { type: "rateLimit", message: "Too many requests. Try again soon." };
if (status === 404) return { type: "notFound", message: body.message || "Resource not found." };
return { type: "error", message: body.message || `Server error (${status})` };
}handleApiError just reads the body once, since a response stream can only be consumed a single time, and hands the status and body to the classifier above.
async function handleApiError(response) {
const body = await response.json().catch(() => ({}));
return classifyStatus(response.status, body);
}Authentication: sending tokens with requests
Most APIs use token-based authentication. The client sends a token in the Authorization header with every request. Building that header is its own small step, since it needs to read the current token and only add the header when one exists.
function buildAuthHeaders(options) {
const token = getAuthToken(); // From localStorage, a cookie, or memory
const headers = { "Content-Type": "application/json", ...options.headers };
if (token) headers["Authorization"] = `Bearer ${token}`;
return headers;
}A 401 response usually means the token expired mid-session rather than that the user was never logged in, so the wrapper gets one chance to refresh the token and retry before giving up and redirecting to login.
async function authenticatedFetch(url, options = {}) {
const headers = buildAuthHeaders(options);
const response = await fetch(url, { ...options, headers });
if (response.status !== 401) return response;
const newToken = await refreshToken();
if (!newToken) {
redirectToLogin();
return response;
}
headers["Authorization"] = `Bearer ${newToken}`;
return fetch(url, { ...options, headers });
}Never put tokens in URL query parameters. URLs appear in server logs, browser history, and referrer headers. The Authorization header is the standard and secure place for tokens.
Building a centralized API client
Instead of writing fetch calls everywhere, create one module that all your API calls go through. Building the request config is its own step: attach JSON headers, add the auth token when one exists, and let any caller-provided options override the defaults.
// api/client.js
const BASE_URL = "https://api.example.com";
function buildConfig(options, token) {
return {
...options,
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
},
};
}Turning the raw response into either a thrown error or parsed data is a second, separate step, so the main request function does not have to interleave status checks with the fetch call itself.
async function parseResponse(response) {
if (response.status === 401) throw new Error("Unauthorized");
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `API error: ${response.status}`);
}
return response.status === 204 ? null : response.json();
}With both helpers in place, request itself is just three steps: build the config, make the call, and parse the result. Every function in the rest of this client calls through this one function.
async function request(endpoint, options = {}) {
const config = buildConfig(options, getAuthToken());
const response = await fetch(`${BASE_URL}${endpoint}`, config);
return parseResponse(response);
}The per-resource module stays thin because all the real logic already lives in request. Each export just supplies the endpoint, method, and body.
// api/users.js
export function getUsers() {
return request("/users");
}
export function getUser(id) {
return request(`/users/${id}`);
}
export function createUser(data) {
return request("/users", { method: "POST", body: JSON.stringify(data) });
}The remaining two follow the same pattern: pass the endpoint, the method, and a body when there is one, letting request handle everything else exactly the way it does for the three functions above.
export function updateUser(id, data) {
return request(`/users/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export function deleteUser(id) {
return request(`/users/${id}`, { method: "DELETE" });
}Now every part of your app imports named functions from api/users.js instead of writing fetch calls, so a UI component never has to know about status codes or request configuration at all.
import { getUser, createUser } from "./api/users";
async function loadProfile(userId) {
try {
const user = await getUser(userId);
renderProfile(user);
} catch (error) {
showError(error.message);
}
}A signup form follows the same shape as the profile loader, just with createUser instead of getUser and a redirect instead of a render.
async function handleSignup(formData) {
try {
const newUser = await createUser(formData);
redirectToProfile(newUser.id);
} catch (error) {
showFormErrors(error.message);
}
}The benefits: the base URL and auth token are centralized, error handling is consistent, and API changes only need to happen in one place.
Request and response flow
The API client sits between your app and the server. It handles authentication, base URLs, error parsing, and response deserialization. Your app only deals with clean data and structured errors.
Every arrow in this diagram happens inside the request function shown earlier. The app layer never sees the token attachment, the fetch call, or the status check; it only sees a resolved promise with data or a rejected one with a readable error message.
Structuring API calls in larger apps
For apps with many endpoints, group related calls into service modules.
api/
client.js # Base request function, auth, error handling
users.js # getUser, createUser, updateUser, deleteUser
posts.js # getPosts, getPost, createPost, deletePost
comments.js # getComments, createComment
auth.js # login, logout, refreshTokenEach module imports the base request function from client.js and exports named functions. Your UI components import only the functions they need.
// pages/Profile.js
import { getUser } from "../api/users";
import { getPosts } from "../api/posts";
async function loadProfile(userId) {
const [user, posts] = await Promise.all([
getUser(userId),
getPosts(userId),
]);
return { user, posts };
}This structure scales from a single-page app to a large application without changing the pattern. Adding a new resource means adding one new file with a handful of exported functions, not touching any of the existing modules or the shared request logic they all depend on.
Common REST API mistakes
Hardcoding URLs everywhere. When the API version changes from /v1/ to /v2/, you should change one line in the client, not hunt through every file.
Not checking response.ok. A 500 error page returned as HTML will be passed to response.json() and crash with a confusing parse error. Always check the status first.
Sending sensitive data in query parameters. Tokens, passwords, and personal data in URLs leak into logs and browser history. Use the request body or headers instead.
Ignoring rate limits. If the server returns 429, stop and wait. Retrying immediately gets you blocked. Read the Retry-After header and respect it.
Mixing API logic with UI logic. Your React component or vanilla JS event handler should call getUser(id), not write fetch boilerplate. Keep API calls separate from rendering logic.
For the Fetch API specifics behind these patterns, see How to Use the JS Fetch API: Complete Tutorial. For Axios-based API clients, see How to Use Axios in JavaScript: Complete Guide. For retry strategies on failed API calls, see API Retry Patterns in JavaScript: Full Tutorial.
Rune AI
Key Insights
- REST APIs use HTTP methods: GET to read, POST to create, PUT/PATCH to update, DELETE to remove.
- Always check the response status. A 200 means success. A 4xx or 5xx needs handling.
- Send authentication tokens in the Authorization header, never in the URL or body.
- Build a centralized API client module instead of scattering fetch calls across your code.
- Structure API calls as named functions (getUser, createPost) that your UI layer imports and uses.
Frequently Asked Questions
What is the difference between REST and GraphQL?
How do I handle authentication in JavaScript API calls?
Should I create one API client or call fetch directly?
Conclusion
REST APIs are the standard way web applications communicate. In JavaScript, you talk to them with fetch or Axios, using the right HTTP method for each operation, sending and receiving JSON, handling status codes, and managing authentication. Build a centralized API client, and every request in your app benefits from the same reliability patterns.
More in this topic
Using Decorators for Logging in JS Architecture
Learn how JavaScript decorators wrap class methods to add logging without touching the original code, including setup with Babel and how execution order works.
JavaScript While Loop Explained: A Complete Guide
A while loop repeats code as long as a condition stays true. Learn the syntax, see practical examples, and understand when a while loop is the right choice over a for loop.
JavaScript Loops Tutorial: for, while, do while
Loops let JavaScript repeat code without writing it twice. Learn the three core loop types, what each one does, and when to choose one over another.