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.

8 min read

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.

javascriptjavascript
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.

javascriptjavascript
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

MethodOperationHas body?Idempotent?Example
GETRead a resourceNoYesGET /users/1
POSTCreate a resourceYesNoPOST /users
PUTReplace a resourceYesYesPUT /users/1
PATCHPartially updateYesNot guaranteedPATCH /users/1
DELETERemove a resourceUsually noYesDELETE /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:

StatusNameTypical response
200OKThe request succeeded
201CreatedA resource was created (POST)
204No ContentSuccess with no response body (DELETE)
400Bad RequestThe request body is malformed
401UnauthorizedMissing or invalid credentials
403ForbiddenValid credentials but no permission
404Not FoundThe resource does not exist
422UnprocessableValidation failed. Check the response body for field errors
429Too Many RequestsRate limited. Wait and retry
500Internal Server ErrorGeneric 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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
// 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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
// 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.

javascriptjavascript
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.

javascriptjavascript
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.

javascriptjavascript
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

REST API request and response lifecycle

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.

texttext
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, refreshToken

Each module imports the base request function from client.js and exports named functions. Your UI components import only the functions they need.

javascriptjavascript
// 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

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.
RunePowered by Rune AI

Frequently Asked Questions

What is the difference between REST and GraphQL?

REST exposes data as resource endpoints (URLs) with standard HTTP methods. GraphQL exposes a single endpoint where the client specifies exactly which fields it wants. REST is simpler and more widely used. GraphQL gives clients more control over the response shape.

How do I handle authentication in JavaScript API calls?

The most common pattern is sending a token in the Authorization header: Authorization: Bearer <token>. Store the token securely (in an httpOnly cookie or memory), and use a request interceptor to attach it to every request.

Should I create one API client or call fetch directly?

Create a shared API client module. It centralizes the base URL, default headers, auth token attachment, error handling, and response parsing. Every API call in your app goes through it, keeping behavior consistent.

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.