A cURL command is a set of request options: method, URL, headers, body, authentication, and redirect behavior. Convert it by mapping each flag to the matching fetch or Axios option, then compare the actual request headers and body rather than assuming the conversion is correct.
- curl -d sends a body and defaults to application/x-www-form-urlencoded; JSON needs an explicit Content-Type header.
- fetch does not reject on HTTP 4xx or 5xx; check response.ok explicitly.
- Browser fetch enforces CORS, while curl and server-side fetch do not.
- Axios serializes JSON automatically when you pass an object as data.
- Always compare the converted request with the original using a echo or request inspector.
Copying a cURL command from API documentation is easy. Turning it into a correct fetch or Axios call is where small details matter: content types, body encoding, authentication, redirects, and error handling.
This guide maps the common flags, shows the equivalent code, and explains how to verify the conversion instead of guessing.
Read the cURL command as options
A cURL command is not a single opaque string. It is a sequence of flags:
curl -X POST 'https://api.example.com/orders' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer TOKEN' \
-d '{"item":"keyboard","quantity":2}'
The equivalent request is:
- Method:
POST - URL:
https://api.example.com/orders - Headers:
Content-Type: application/json,Authorization: Bearer TOKEN - Body:
{"item":"keyboard","quantity":2}
Once you list those four things, the JavaScript version is straightforward.
The four-line summary omits information that changes the request. -d implies POST when -X is absent, -I implies HEAD, and -G moves the data into the query string instead of the body. Other flags do not map to browser fetch at all: --resolve, --cacert, --cert, --proxy, --http1.1, and --connect-timeout configure the transport, not the HTTP request. They belong to a server-side client or a proxy; dropping them silently is how "it worked with curl" becomes a different request in the browser.
Shell quoting is part of the request too. Single quotes protect $ and !; double quotes allow expansion, so a command containing $TOKEN may have been substituted in the documentation's environment or may expand to an empty string in yours. Multiline JSON passed to -d '...' keeps its newlines only because of the quotes; retyping it without them changes the body.
The flag-to-option map
| cURL flag | Meaning | fetch | Axios |
|---|---|---|---|
-X POST | HTTP method | method: "POST" | method: "post" |
-H 'Name: value' | Request header | headers: { Name: "value" } | headers: { Name: "value" } |
-d '...' | Request body | body: "..." | data: "..." |
--data-raw | Body without file interpretation | body: "..." | data: "..." |
--data-binary @file | Binary body from a file | body: fileBlob | data: fileBlob |
-F 'name=value' | Multipart form field | FormData | FormData |
-u user:pass | Basic authentication | Authorization header | auth: { username, password } |
-b 'name=value' | Cookie | credentials or Cookie header | withCredentials or header |
-L | Follow redirects | redirect: "follow" (default) | maxRedirects (default follows) |
-k | Skip TLS verification | Not available in browsers | httpsAgent option (Node only) |
--json '{...}' | JSON body plus JSON content type (curl 7.82+) | body: JSON.stringify(...) plus header | data: {...} |
-G | Move data into the query string | Build with URLSearchParams | params |
--data-urlencode | URL-encode a form field | URLSearchParams | params |
-I | HEAD request | method: "HEAD" | method: "head" |
-m 10 | Whole-transfer timeout | signal: AbortSignal.timeout(10000) | timeout: 10000 or signal |
--retry 3 | Retry failed transfers | Implement a loop with backoff | Implement an interceptor |
--compressed | Request compressed response | Handled automatically | Handled automatically |
Two mappings deserve a warning. --data-urlencode encodes each field as form data; fetch has no equivalent flag, so build a URLSearchParams instead of appending the string to the URL by hand. -m 10 limits the whole transfer, while Axios timeout is interpreted differently across adapters; for a precise deadline, use AbortSignal.timeout with both fetch and Axios. --retry has no direct equivalent at all, and a retry policy must know whether the request is idempotent. Retrying a POST can create duplicate orders, so add retries only for safe methods or when the API supports idempotency keys, with jittered backoff.
GET with headers
curl 'https://api.example.com/orders?status=open' \
-H 'Accept: application/json' \
-H 'Authorization: Bearer TOKEN'
const response = await fetch("https://api.example.com/orders?status=open", {
method: "GET",
headers: {
Accept: "application/json",
Authorization: "Bearer TOKEN"
}
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${await response.text()}`);
}
const data = await response.json();
Axios:
const { data } = await axios.get("https://api.example.com/orders", {
params: { status: "open" },
headers: { Authorization: "Bearer TOKEN" }
});
Axios serializes params into the query string. With fetch, build the query string with URLSearchParams.
The query-string details cause quiet mismatches. URLSearchParams encodes spaces as +, which is correct for form encoding but may surprise an API that expects %20. Repeated keys require append: an object literal cannot represent status=open&status=paid, because the second value overwrites the first. Axios serializes arrays with brackets by default (status[]=open), so an API that expects repeated bare keys needs a custom paramsSerializer.
fetch also refuses a body on GET or HEAD. new Request(url, { method: "GET", body: "..." }) throws TypeError: Request with GET/HEAD method cannot have body. curl will happily send -X GET -d '...'; the browser API will not, so that request needs a different design rather than a different option.
POST JSON
curl -X POST 'https://api.example.com/orders' \
-H 'Content-Type: application/json' \
-d '{"item":"keyboard","quantity":2}'
const response = await fetch("https://api.example.com/orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ item: "keyboard", quantity: 2 })
});
Axios:
const { data } = await axios.post(
"https://api.example.com/orders",
{ item: "keyboard", quantity: 2 },
{ headers: { "Content-Type": "application/json" } }
);
Axios sets the JSON content type automatically when data is an object. With fetch, you must set it and stringify the body yourself.
JSON.stringify is not a transparent copy of the cURL body. Properties with undefined values disappear, Date objects become ISO strings through toJSON, BigInt values throw TypeError: Do not know how to serialize a BigInt, and circular references throw Converting circular structure to JSON. If the original body was hand-written, the stringified version may also differ in whitespace, key order after object reconstruction, and escaping of non-ASCII characters. Most servers do not care; signature schemes do.
Axios adds the JSON content type only when it serializes an object itself. If you pass an already-stringified body, set the header explicitly, or the request may go out as text/plain and fail content negotiation.
The content-type trap
curl -d defaults to application/x-www-form-urlencoded, not JSON. If the command includes -H 'Content-Type: application/json', the header wins. If it does not, the server receives form-encoded data.
For form-encoded requests, use URLSearchParams:
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: "abc",
client_secret: "secret"
});
const response = await fetch("https://auth.example.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body
});
The form URL encoded converter helps inspect the encoded value.
curl's -d @file reads the file and strips newlines and carriage returns; --data-binary @file preserves the exact bytes. A pretty-printed JSON fixture sent with -d @payload.json arrives as one line, which is usually harmless, but a body that depends on newlines β NDJSON, a signature, a PEM block β is corrupted. Use --data-binary when the file's bytes matter.
URLSearchParams used as a fetch body sets Content-Type: application/x-www-form-urlencoded;charset=UTF-8 automatically. Setting a bare application/x-www-form-urlencoded header manually overrides that value, and some servers are strict about the charset parameter. Prefer letting the object set the header, or include the parameter explicitly. If both a proxy and the application add a content type, the duplicate header is another source of 415 responses.
Basic authentication
curl -u 'user:password' 'https://api.example.com/me'
const credentials = btoa("user:password");
const response = await fetch("https://api.example.com/me", {
headers: { Authorization: `Basic ${credentials}` }
});
Axios:
const { data } = await axios.get("https://api.example.com/me", {
auth: { username: "user", password: "password" }
});
In a browser, btoa fails on non-Latin characters. Encode the credentials as UTF-8 bytes first. The Basic auth header generator handles this for you.
RFC 7617 splits the decoded credential at the first colon: the user-id cannot contain a colon, while the password can. A username containing a colon produces a credential the server parses incorrectly, usually as a different user or as an authentication failure. Non-ASCII credentials have no universally agreed charset; the standard allows a charset parameter, and without it some servers assume UTF-8 while others assume Latin-1. If the API documents one, encode those bytes deliberately instead of assuming btoa did the right thing.
Basic authentication in a browser is visible to the user and to anything that can read the page; it is not a secret-storage mechanism. The password is also sent on every request. Use it only over TLS, and prefer a short-lived token when the API offers one.
File uploads
curl -X POST 'https://api.example.com/upload' \
-F 'file=@report.pdf' \
-F 'description=Monthly report'
const form = new FormData();
form.append("file", fileInput.files[0]);
form.append("description", "Monthly report");
const response = await fetch("https://api.example.com/upload", {
method: "POST",
body: form
});
Do not set Content-Type manually. The browser adds multipart/form-data with the correct boundary. Setting the header yourself usually produces a request the server cannot parse.
-F carries more than a field name. -F 'file=@report.pdf;type=application/pdf' sets the part's content type, and -F 'file=@photo.jpg;filename=renamed.jpg' overrides the filename the server sees. In fetch, form.append("file", blob, "renamed.jpg") supplies the filename, and a File already carries its type; a plain Blob may need an explicit type when constructed. Multiple files under one field use repeated append calls with the same field name.
Fetch cannot report upload progress. If the UI needs a progress bar, use XMLHttpRequest or Axios's onUploadProgress, which is backed by XHR in browsers. Axios in Node may need the form-data package or a runtime with a compatible global FormData; test the runtime instead of assuming browser code works server-side.
Redirects and cookies
fetch follows redirects by default, but the behavior differs for cross-origin redirects and credentialed requests. If the original cURL command relied on -L and cookies, check both:
- Browser fetch: use
credentials: "include"for cross-origin cookies. - Node fetch: cookies are not managed automatically; pass the
Cookieheader. - Axios browser:
withCredentials: true. - Axios Node: manage cookies with a cookie jar or pass the header.
fetch's redirect option has three values: follow (the default), error, and manual. In browsers, manual returns an opaque-redirect response with status 0, so the Location header cannot be read; in Node's fetch implementation, manual exposes the 3xx response. A conversion that reads redirect targets may therefore work in Node and fail in the browser. Fetch also caps redirect chains and rejects with a TypeError when the limit is exceeded, rather than returning the last response.
Cookies are not part of fetch's API. In the browser, credentials: "include" lets the browser attach and store cookies according to its cookie policy, but JavaScript still cannot read Set-Cookie. In Node, fetch has no cookie jar at all, so a login flow needs explicit header handling or a client library with a jar. Axios in Node has the same limitation; withCredentials only affects browsers.
Verify the conversion
The most reliable check is to compare the actual requests. Use the HTTP request workbench or the cURL converter to inspect both forms.
Check:
- Method and URL, including query parameters.
- Every header, especially
Content-TypeandAuthorization. - The body bytes. JSON key order usually does not matter, but encoding and whitespace can.
- Redirect behavior.
- Error handling. A fetch call that resolves with a 401 is not a success.
A conversion is correct when the server receives the same request, not when the code looks similar.
If the request is signed, byte fidelity is mandatory. AWS Signature Version 4 and webhook HMAC schemes sign a canonical request that includes a hash of the exact body. Rebuilding the body with JSON.stringify can reorder keys or change escaping, and the server rejects the signature even though the JSON is semantically identical. In that case, keep the original body as a string and sign those bytes.
Chrome and Firefox can export a request as cURL from the network panel ("Copy as cURL"), which is the fastest way to compare the browser's version with the original command. curl's --trace-ascii prints the bytes sent and received, headers included, which is more precise than -v when whitespace or encoding is in question.
A note on browser vs server
A cURL command often runs outside the browser. If you convert it to client-side fetch, the browser adds constraints:
- CORS preflight for non-simple requests.
- No access to forbidden headers.
- Credentials require explicit configuration.
- Mixed content is blocked.
The forbidden header list explains several failed conversions. Browser fetch silently drops attempts to set Host, Cookie, Content-Length, Origin, and Referer. A server that requires a manually computed Content-Length or a specific Cookie header cannot be called from browser JavaScript; move that request server-side.
The final constraint is secrecy. A cURL command in documentation contains an API key because the reader is expected to run it on a trusted machine. Moving the same key into browser code publishes it to every visitor. If the API uses a secret key, the browser should call your backend and the backend should call the API. A conversion that works is still the wrong architecture if it ships a secret.
If the API is not designed for browser access, keep the request on your server and expose a narrow endpoint to the client. Do not disable browser security to make a copied command work.
Frequently asked questions
Why does my converted fetch request return a different response?
The most common causes are a missing Content-Type header, a body serialized differently, a missing Authorization header, or a redirect that curl followed but fetch did not. Compare the request headers and body byte-for-byte with the original command.
Does fetch throw an error for a 500 response?
No. fetch resolves successfully for any HTTP status, including 4xx and 5xx. Check response.ok or response.status and handle the error body explicitly.
How do I convert curl -u user:password to fetch?
Build a Basic Authorization header by base64-encoding the username and password with a colon between them, then send it as Authorization: Basic <value>. Do not put credentials in a URL.
Why does curl work but the browser fetch fails with CORS?
curl does not enforce the browser same-origin policy. The browser sends a preflight for many cross-origin requests and blocks the response if the server does not return the required CORS headers. The server needs a CORS configuration, not a different fetch call.
How do I send a file with fetch?
Use FormData and append the file and any other fields. Do not set the Content-Type header manually; the browser adds the multipart boundary. Axios accepts the same FormData object.