Handling Post Requests with JS Fetch API Guide
Learn how to send POST requests with the Fetch API. Set JSON and FormData bodies, handle server responses, manage headers, and avoid common POST mistakes.
Fetch POST requests send data to a server to create or update a resource, unlike a GET request which only reads existing data and carries no body. With the Fetch API, you switch from the default GET by passing an options object with method, headers, and body.
async function createPost(title, content) {
const response = await fetch("https://api.example.com/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, content }),
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
return response.json();
}The function throws early if the server rejects the request, so anything after the ok check can safely assume the post was actually created. Calling the function and logging the result shows the full request in action, from the initial call through to the server's generated ID coming back in the response.
createPost("Hello World", "My first post").then((post) => {
console.log(`Created post #${post.id}`);
});The three required pieces are method: "POST", a Content-Type header that tells the server what format the body is in, and a body that is serialized to match that header.
Sending JSON in a POST body
JSON is the most common format for REST APIs. You stringify a JavaScript object and tell the server it is JSON.
async function updateUser(userId, data) {
const response = await fetch(`https://api.example.com/users/${userId}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer my-token",
},
body: JSON.stringify(data),
});
return handleUserResponse(response);
}A small helper reads the response, throwing a readable error if the update failed instead of silently returning bad data.
async function handleUserResponse(response) {
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
throw new Error(errorBody.message || `HTTP ${response.status}`);
}
return response.json();
}The Content-Type header must match the body format. If you send JSON.stringify(data) but set Content-Type to text/plain instead, the server may reject the request outright or attempt to parse a JSON string as something else entirely.
JSON.stringify throws on circular references and silently drops certain values instead of sending them, so make sure the object you pass is actually serializable. A few common surprises: function values are stripped out entirely, undefined properties disappear rather than becoming null, and Date objects are converted to ISO date strings, which is usually fine but worth knowing about ahead of time.
A plain object built only from primitive values, arrays, and nested plain objects stringifies exactly the way you would expect.
const good = {
name: "Taylor",
age: 28,
tags: ["javascript", "async"],
metadata: { role: "admin" },
};Sending FormData
FormData is the right choice when you are submitting a form, uploading files, or sending mixed content types that a plain JSON body cannot represent, such as a text field alongside a binary file in the same request. The browser automatically sets the Content-Type to multipart/form-data with the correct boundary, a random string used to separate each field's data inside the request body.
async function submitForm(formElement) {
const formData = new FormData(formElement);
const response = await fetch("/api/submit", {
method: "POST",
body: formData,
// Do NOT set Content-Type. The browser does it automatically.
});
return handleUserResponse(response);
}This reuses the same handleUserResponse helper from the JSON example above, since checking response.ok and parsing the body works the same way regardless of what the body was made of. Wiring the function to a real form's submit event stops the browser's default full-page navigation, which would otherwise reload the page before the fetch call even finishes.
document.querySelector("form").addEventListener("submit", async (event) => {
event.preventDefault();
const result = await submitForm(event.target);
console.log("Submitted:", result);
});You can also build a FormData object programmatically instead of reading it from an actual form element, which is useful when the fields come from application state rather than user input in a form.
const formData = new FormData();
formData.append("username", "taylor");
formData.append("bio", "JavaScript developer");
formData.append("avatar", fileInput.files[0]); // A File object
await fetch("/api/profile", { method: "POST", body: formData });The key rule is to never set Content-Type manually when the body is FormData. The browser adds the header with a unique boundary string, and overriding it breaks multipart parsing on the server.
Sending URL-encoded data
Some older APIs, and some login or OAuth endpoints in particular, still expect the application/x-www-form-urlencoded format instead of JSON. Use URLSearchParams to build the body, since it produces the exact key=value&key=value shape that format requires without any manual string concatenation, matching what a real HTML form would send on its own.
async function login(username, password) {
const body = new URLSearchParams({ username, password });
const response = await fetch("/api/login", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: body.toString(),
});
return response.json();
}URLSearchParams handles percent-encoding automatically. Spaces become %20, ampersands become %26, and so on. You never need to manually encode values, and calling toString on the instance produces the exact string fetch expects for the body.
Reading the server response
After a POST, the server typically returns the created resource, a status message, or validation errors describing exactly what went wrong. Always read and handle the response body instead of assuming a resolved fetch promise means the operation actually succeeded, starting with the request itself wrapped in its own try...catch.
async function sendPost(url, data) {
try {
return await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
} catch (err) {
throw new Error("Network error: could not reach the server.");
}
}The wrapper below calls that helper, then always tries to parse a JSON body regardless of the status code, since APIs often return error details in the same JSON shape as a success. Falling back to null when parsing fails means a non-JSON error page does not crash the wrapper itself, it just leaves the message unavailable.
async function safePost(url, data) {
const response = await sendPost(url, data);
const body = await response.json().catch(() => null);
if (!response.ok) {
const message = body?.error || body?.message || `HTTP ${response.status}`;
throw new Error(message);
}
return body;
}Three outcomes are covered here: network failure from the first helper, server error when response.ok is false, or success. The pattern handles all three and always tries to extract a useful error message from the response body.
POST with PUT and PATCH
The same body-handling rules apply to PUT and PATCH, which are close cousins of POST that differ mainly in intent rather than mechanics. PUT means replace the entire resource with the body you send, while PATCH means update only the fields included in the body and leave the rest untouched. The only difference in the actual fetch call is the HTTP method string.
// PUT: replace the entire resource
await fetch(`/api/users/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, role }),
});Notice that the PUT body includes every field the resource has, since a real replace operation on the server would otherwise treat any missing field as cleared. PATCH takes the opposite approach and sends only what actually changed.
// PATCH: update only the fields you provide
await fetch(`/api/users/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }), // Only updating the email
});The body serialization, error checking, and response parsing are identical across POST, PUT, and PATCH. The method name is the only thing that changes.
Common POST mistakes
Forgetting JSON.stringify. Fetch requires the body to be a string, FormData, Blob, or similar. Passing a plain object directly does not throw an error, which makes this mistake especially easy to miss; it silently converts the object to the generic string "[object Object]" instead.
// Mistake: passing a plain object
body: { name: "Taylor" } // Sends the string "[object Object]"
// Correct: stringify
body: JSON.stringify({ name: "Taylor" })Setting Content-Type for FormData. The browser needs to set its own boundary. Overriding it breaks multipart parsing on the server.
// Mistake: overriding the Content-Type for FormData
headers: { "Content-Type": "multipart/form-data" }The fix is simply not setting that header at all, since the browser only fills in the correct boundary value when Content-Type is left unset.
// Correct: omit Content-Type entirely when using FormData
await fetch("/api/profile", { method: "POST", body: formData });Not checking response.ok. A POST that returns a 422 or 500 still resolves the fetch promise rather than rejecting it, which surprises anyone used to libraries where a bad status code throws automatically. If you call the json method without checking first, you might parse an error page as if it were valid data.
Ignoring the response body. After a successful POST, the server may return the created resource with server-generated fields like id, createdAt, or updatedAt that only exist once the record has actually been saved. If you do not read the response, you lose that data and have no way to reference the new record afterward without a separate request.
// Mistake: ignoring the response
await fetch("/api/posts", { method: "POST", body: JSON.stringify(newPostData) });
// The server returned { id: 123, title: "..." } but we did not read itCapturing the response and reading its body gives you access to the fields the server actually generated, like the new record's ID.
// Correct: capture the response
const response = await fetch("/api/posts", { method: "POST", body: JSON.stringify(newPostData) });
const newPost = await response.json();
console.log(`Created post #${newPost.id}`);For the fundamentals of the Fetch API including GET requests and response handling, see How to Use the JS Fetch API: Complete Tutorial. For file uploads with FormData and progress tracking, see Uploading Files Via JS Fetch API: Complete Guide.
Rune AI
Key Insights
- Set method: 'POST' and provide a body to send data to a server.
- Use JSON.stringify() for JSON bodies and set Content-Type to application/json.
- Use FormData for file uploads, form submissions, and mixed content.
- Always check response.ok after a POST. A server error does not reject the fetch promise.
- The browser sets Content-Type for FormData automatically. Do not override it.
Frequently Asked Questions
Do I need to set Content-Type for every POST request?
What is the difference between fetch POST and GET?
How do I send both JSON and a file in one POST request?
Conclusion
POST requests with fetch follow a consistent pattern: set the method to POST, provide the right Content-Type header, serialize the body correctly, and always check response.ok. Once you can send JSON and FormData, you can talk to any REST API.
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.