Next.js

SyntaxError: Unexpected end of input with mode: 'no-cors'

The JSON is in DevTools but never reaches your code: see why an opaque response has nothing to parse, and the two fixes that actually return the data.

Mahdi Ben RhoumaMahdi Ben Rhouma
7 min read

TL;DR#

mode: 'no-cors' does not get you past CORS: it disables CORS for the request, so the browser hands your script an opaque response. The request still reaches the server and the server still answers with JSON, but your code gets a response with status 0, no headers and a null body, so response.json() has nothing to parse and throws. Remove mode: 'no-cors', then make the API send Access-Control-Allow-Origin and answer the OPTIONS preflight, or call the API from your own server so the browser request becomes same-origin.

The error from the Stack Overflow question, reproduced in Chromium:

text
[chromium S1] response.json() threw SyntaxError: Unexpected end of input

The request that triggers it#

The question posts JSON to an API on another port from a React app and parses the reply:

js
fetch('http://localhost:8080/course', {
  method: 'POST',
  mode: 'no-cors',
  credentials: 'same-origin',
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    objectClass: 'course',
    crud: '2',
  }),
})
  .then(function (response) {
    console.log(response);
    return response.json();
  })
  .catch(function (err) {
    console.log(err);
  });

The author checked the JSON with a linter and it was valid. That is the first clue that the payload is not the problem: the error says the input ended, not that it was malformed.

This exact request was reproduced on 19 October 2026 in the official Playwright v1.63.0 Docker image: a page served from http://localhost:3000, a bare node:http API on port 8080 that returns a JSON array, and the browsers bundled with that image (Chromium 153.0.8010.12 and Firefox 155.0). The full scripts and raw output are kept with the article's reproduction log. Chromium's log for that request:

text
--- chromium S1: mode: 'no-cors' POST to :8080 (no CORS headers) - the question
[server:8080] POST /course origin=http://localhost:3000 content-type=text/plain;charset=UTF-8 accept=application/json body={"objectClass":"course","crud":"2"}
[chromium S1] response.type=opaque status=0 ok=false headers=0 body=null
[chromium S1] response.text()=""
[chromium S1] response.json() threw SyntaxError: Unexpected end of input
[chromium network] POST http://localhost:8080/course -> 200 body=[{"id":"course_00001","title":"WWI 14 SEA","objectClass":"course"}]

Read it top to bottom. The server received the POST and answered 200 with the array (last line, captured at the network layer). Your script, on the other hand, saw a response of type opaque with status 0, zero headers and a null body; response.text() returned an empty string, and response.json() threw on that empty string.

Why it happens#

The Fetch Standard defines exactly what a script gets back from a no-cors request to another origin. In its definition of an opaque filtered response, the type is opaque, the status is 0, the header list is empty and the body is null. MDN's page on Request.mode says the same in plainer words: setting no-cors "places restrictions on the request methods and headers that may be used, and prevents JavaScript from accessing the response headers or body."

So response.json() is not failing to parse your JSON. It is parsing nothing, because the browser has replaced the body with null before your code sees it. The accepted answer on the question states it directly: "Setting no-cors mode is exactly the cause of the problem you're having", and "Opaque means your frontend JavaScript code can't see the response body or headers."

The proof that the input is empty rather than invalid is the response.text() line in the log above: it returned an empty string. Firefox makes the link even plainer, because it prints the same sentence for the opaque response and for an empty string parsed by hand:

text
[firefox] JSON.parse('') -> SyntaxError: JSON.parse: unexpected end of data at line 1 column 1 of the JSON data

Chromium words the two cases slightly differently (Unexpected end of input from response.json(), Unexpected end of JSON input from JSON.parse('')), so do not search for one exact string only.

Developers reach for no-cors because the request first failed with a CORS error. The accepted answer names the two usual triggers: the response has no Access-Control-Allow-Origin header, or the request needs a preflight (here, because Content-Type: application/json is not a simple content type). no-cors silences that console error only by giving up the response.

Fix#

1. Remove mode: 'no-cors' from the fetch call#

Delete the line. The default mode for fetch() is cors, which is what you want for an API whose data you need to read:

js
const response = await fetch('http://localhost:8080/course', {
  method: 'POST',
  credentials: 'same-origin',
  headers: {
    'Accept': 'application/json',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ objectClass: 'course', crud: '2' }),
});
const courses = await response.json();

If the API does not allow your origin yet, the SyntaxError is now replaced by the real problem. Against the same API with no CORS headers, Chromium sent only the preflight and stopped:

text
--- chromium S4: mode removed (default cors) POST to :8080 (no CORS headers)
[server:8080] OPTIONS /course origin=http://localhost:3000 content-type=- accept=*/* body=-
[chromium console.error] Access to fetch at 'http://localhost:8080/course' from origin 'http://localhost:3000' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
[chromium console.error] Failed to load resource: net::ERR_FAILED
[chromium S4] fetch rejected: TypeError: Failed to fetch

That is progress: this error says what to change. The full walkthrough of this message, including the credentials and wildcard rules, is in Fix: blocked by CORS policy from origin localhost:3000.

2. Make the API allow your origin and answer the preflight#

The fix belongs on the server you call. It has to send Access-Control-Allow-Origin on the real response and handle the OPTIONS request the browser sends first. This minimal node:http server was run as printed here, and Chromium called it with the step 1 request:

js
import http from 'node:http';
 
const ALLOWED_ORIGIN = 'http://localhost:3000';
 
http.createServer((req, res) => {
  const cors = {
    'Access-Control-Allow-Origin': ALLOWED_ORIGIN,
    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Accept',
  };
  if (req.method === 'OPTIONS') {
    res.writeHead(204, cors);
    return res.end();
  }
  res.writeHead(200, { 'Content-Type': 'application/json', ...cors });
  res.end(JSON.stringify([{ id: 'course_00001', title: 'WWI 14 SEA' }]));
}).listen(8081);
text
===== snippet test: chromium 153.0.8010.12 against cors-server.js =====
[snippet] response.type=cors status=200 ok=true
[snippet] response.json()=[{"id":"course_00001","title":"WWI 14 SEA"}]

Use your real front-end origin in production (for example https://app.example.co.uk), not *, if the request ever carries cookies.

3. Or call the API from your own server#

When you do not control the API, the accepted answer's other option is a proxy. In a Next.js app, the natural proxy is a Route Handler: the browser calls your own origin, and the server-side code calls the API. CORS is a browser rule, and Node's fetch does not apply it. In the reproduction, Node v24.20.0's global fetch sent the same request, mode: 'no-cors' included, and read the body; the browser-only header rewrite did not happen either:

text
--- node S1: mode: 'no-cors' POST to :8080 (no CORS headers) - the question
[server:8080] POST /course origin=- content-type=application/json accept=application/json body={"objectClass":"course","crud":"2"}
[node S1] response.type=basic status=200
[node S1] response.json()=[{"id":"course_00001","title":"WWI 14 SEA","objectClass":"course"}]

A Route Handler that forwards the call looks like this (app/api/course/route.js). The Node fetch half was reproduced above; the wrapper follows the shape in the Next.js docs and was not run in a Next.js app for this article:

js
export async function POST(request) {
  const payload = await request.json();
  const upstream = await fetch('http://127.0.0.1:8080/course', {
    method: 'POST',
    headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
    body: JSON.stringify(payload),
  });
  return Response.json(await upstream.json(), { status: upstream.status });
}

The browser then calls fetch('/api/course', …) with no mode option. Two server-side errors are common at this step. If you write localhost instead of 127.0.0.1 and the handler fails with ECONNREFUSED ::1, the API listens on IPv4 only: see FetchError request to localhost ECONNREFUSED ::1. A POST Route Handler like this one runs per request. If you fetch the API from a prerendered Server Component instead, and it fails with ENOTFOUND only in a Docker production image, that fetch is running at build time: see Docker hostname ENOTFOUND in Next.js prod.

4. Fail loudly on an opaque response#

So the next person to add mode: 'no-cors' does not lose an afternoon, check the response type before parsing:

js
async function readJson(response) {
  if (response.type === 'opaque') {
    throw new Error("Opaque response: remove mode: 'no-cors' to read the body");
  }
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  return response.json();
}

Check your own CORS error#

If the console still shows a "blocked by CORS policy" line after steps 1 and 2, paste it below, with the response headers from DevTools if you have them. It names the exact case and the server-side fix for your stack. It runs in your browser; nothing is sent.

Everything runs in your browser — nothing you paste is sent anywhere, and tokens, cookies and passwords are masked in the output.

Optional: response headers (curl -i / curl -v output)
Try:

Verify the fix#

Run the request again and log response.type before parsing. With the mode removed and a server that allows the origin, Chromium returned a cors response with the data:

text
--- chromium S5: mode removed (default cors) POST to :8081 (CORS headers + OPTIONS)
[server:8081] OPTIONS /course origin=http://localhost:3000 content-type=- accept=*/* body=-
[server:8081] POST /course origin=http://localhost:3000 content-type=application/json accept=application/json body={"objectClass":"course","crud":"2"}
[chromium S5] response.type=cors status=200 ok=true headers=1 body=stream
[chromium S5] response.text()="[{\"id\":\"course_00001\",\"title\":\"WWI 14 SEA\",\"objectClass\":\"course\"}]"
[chromium S5] response.json()=[{"id":"course_00001","title":"WWI 14 SEA","objectClass":"course"}]

Firefox 155.0 gave the same result:

text
[firefox S5] response.type=cors status=200 ok=true headers=1 body=stream

Two things to look for: the server now logs an OPTIONS request followed by the POST, and the type is cors, not opaque. If you went the Route Handler way, the request in DevTools goes to your own origin, so expect the basic type that the same-origin call (S7) returned in the reproduction.

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.

Why the server-side headers alone change nothing#

Fixing only one side is the usual dead end. The reproduction pointed the same no-cors request at the server that does send Access-Control-Allow-Origin, and the script still got nothing:

text
--- chromium S2: mode: 'no-cors' POST to :8081 (server sends CORS headers) - negative control
[server:8081] POST /course origin=http://localhost:3000 content-type=text/plain;charset=UTF-8 accept=application/json body={"objectClass":"course","crud":"2"}
[chromium S2] response.type=opaque status=0 ok=false headers=0 body=null
[chromium S2] response.text()=""
[chromium S2] response.json() threw SyntaxError: Unexpected end of input

The mode decides, not the headers. A cross-origin no-cors response is opaque whatever the server sends, so when the browser calls the API directly, step 1 and step 2 are both needed. When the call goes through your own origin (step 3), removing the mode is enough.

The second answer on the question suggests checking response.ok first. With an opaque response, ok is false (status 0), so the check does fire, and it replaces the misleading SyntaxError with a clearer failure. It does not return the data:

text
[chromium S3] response.type=opaque status=0 ok=false headers=0 body=null
[chromium S3] ok check: Error: HTTP 0

What no-cors silently changes in the request#

no-cors also rewrites what you send. The Fetch Standard puts the request headers under a no-cors guard that drops any header which is not safelisted, and a JSON content type is not. In the server log of S1 and S2 above, the API received text/plain;charset=UTF-8 instead of application/json, with the JSON string as the body. A server that only parses bodies declared as JSON will therefore see a different request in no-cors mode than in cors mode (S5 received application/json).

The method is restricted too. The spec allows only GET, HEAD and POST in no-cors mode, and anything else throws before a request is sent:

text
[chromium S6] fetch rejected: TypeError: Failed to execute 'fetch' on 'Window': 'PUT' is unsupported in no-cors mode.

The error text depends on the browser. Firefox 155.0 threw this for the same opaque response, so search for both wordings:

text
[firefox S1] response.json() threw SyntaxError: JSON.parse: unexpected end of data at line 1 column 1 of the JSON data

Same-origin responses are not hidden. A no-cors POST to the page's own origin (port 3000) returned a basic response with status 200 and parsed normally, which is why the call works once it goes through a proxy on your own domain. The header rewrite still applied to that request, though: the server logged text/plain;charset=UTF-8 there too, so drop the mode on same-origin calls as well. Finally, if DevTools shows the response body but your code gets an empty string, believe the code: the Network panel shows what the browser received, not what it handed to the script. A different DevTools quirk with a similar symptom, a body missing in the panel while the app has it, is covered in response data evicted from inspector cache.

no-cors is useful when you only need the request to arrive, such as a fire-and-forget beacon. For any call whose response you need to read, remove the option and fix the CORS policy where it lives, on the server, or move the call behind your own origin. Once the mode is gone, the error you see is a "blocked by CORS policy" message, and that one tells you which header to add.

Further reading: Set the request's mode to 'no-cors'? What to do instead. Also see Create React App's CORS Header Goes on the API, Not the App.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote