If you’re trying to call the Webflow CMS API from browser JavaScript, CORS is usually the first wall you hit.
The short version: Webflow CMS API requests from the browser are a bad fit unless Webflow explicitly allows your origin. Even when the API works fine in Postman or curl, the browser enforces CORS and blocks the response before your code can touch it.
This guide is the practical version: what CORS means for Webflow CMS, what will fail, what can work, and what to copy-paste.
The mental model
CORS is a browser permission system layered on top of HTTP.
Your page is loaded from one origin:
https://www.yoursite.com
Your JavaScript tries to call another origin:
https://api.webflow.com
That’s cross-origin. The browser checks the response headers from api.webflow.com to decide whether your frontend code is allowed to read the response.
If the API does not return the right headers, the browser blocks access even though the network request may have succeeded.
A permissive public API might send something like this:
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
That’s a real pattern from api.github.com. It tells the browser:
- any origin can read the response with
* - JavaScript may also read those listed non-simple response headers
That kind of response is friendly to browser-based apps.
Why Webflow CMS runs into CORS trouble
With Webflow CMS, there are really two different cases:
- Reading published CMS content from your site’s frontend
- Calling the Webflow Data API / CMS management endpoints directly from browser code
These are not the same thing.
Case 1: Reading published content
If your content is already rendered onto the page by Webflow, there’s no CORS problem. The browser is just loading HTML from your own site.
If you fetch content from a same-origin endpoint you control, also no CORS problem.
Case 2: Calling the Webflow API from browser JavaScript
This is where people get burned.
If you do this from frontend code:
fetch("https://api.webflow.com/collections/COLLECTION_ID/items", {
headers: {
Authorization: "Bearer YOUR_TOKEN"
}
})
you’ve got two issues:
- CORS
- credential leakage
Even if CORS were allowed, putting a Webflow API token in client-side code is not acceptable. Anyone can open DevTools and steal it.
My rule is simple: never call authenticated Webflow CMS endpoints directly from browser JavaScript.
What a browser CORS failure looks like
You’ll usually see one of these in DevTools:
Access to fetch at 'https://api.webflow.com/...' from origin 'https://www.yoursite.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
Or:
Response to preflight request doesn't pass access control check
Or the classic misleading one:
TypeError: Failed to fetch
That last one is why people waste hours. It often looks like a networking issue when it’s really CORS.
The request that triggers preflight
A “simple” GET sometimes skips preflight. But the moment you add Authorization, JSON content types, or non-simple headers, the browser sends an OPTIONS preflight first.
This request:
fetch("https://api.webflow.com/collections/COLLECTION_ID/items", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer YOUR_TOKEN"
},
body: JSON.stringify({
fieldData: {
name: "Hello",
slug: "hello"
}
})
})
typically triggers a preflight like:
OPTIONS /collections/COLLECTION_ID/items HTTP/1.1
Origin: https://www.yoursite.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
For the browser to continue, the server must answer with something like:
Access-Control-Allow-Origin: https://www.yoursite.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
If that response is missing or incomplete, the browser stops there.
What you should do instead
For Webflow CMS API access, use a backend or serverless function as your proxy.
That gives you:
- no exposed API token
- no browser-to-Webflow CORS issue
- a place to validate input, rate-limit, cache, and log
This is the right architecture almost every time.
Safe proxy pattern
Browser calls your endpoint:
https://www.yoursite.com/api/webflow/items
Your server calls Webflow:
https://api.webflow.com/...
Because the browser is talking to your own origin, CORS is either not involved or easy to control.
Browser code
async function loadItems() {
const res = await fetch("/api/webflow/items");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
loadItems()
.then(data => console.log(data))
.catch(err => console.error(err));
Node/Express proxy example
import express from "express";
const app = express();
app.get("/api/webflow/items", async (req, res) => {
try {
const webflowRes = await fetch(
"https://api.webflow.com/collections/YOUR_COLLECTION_ID/items/live",
{
headers: {
Authorization: `Bearer ${process.env.WEBFLOW_TOKEN}`,
Accept: "application/json"
}
}
);
const text = await webflowRes.text();
res.status(webflowRes.status);
res.set("Content-Type", webflowRes.headers.get("content-type") || "application/json");
res.send(text);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Proxy request failed" });
}
});
app.listen(3000, () => {
console.log("Server listening on http://localhost:3000");
});
That’s the baseline version. Good enough for internal tools and prototypes.
If your frontend and API are on different origins
Maybe your Webflow site lives on:
https://www.yoursite.com
and your proxy API lives on:
https://api.yoursite.com
Now CORS matters again, but this time you control it.
Express CORS middleware example
import express from "express";
const app = express();
app.use((req, res, next) => {
const allowedOrigins = [
"https://www.yoursite.com",
"https://yoursite.webflow.io"
];
const origin = req.headers.origin;
if (allowedOrigins.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
}
if (req.method === "OPTIONS") {
return res.sendStatus(204);
}
next();
});
A few opinions here:
- Don’t use
*if you’re dealing with authenticated requests. - Always send
Vary: Originwhen dynamically reflecting allowed origins. - Keep the allowed origin list small and explicit.
Copy-paste response headers reference
These are the headers you’ll care about most.
Allow one specific origin
Access-Control-Allow-Origin: https://www.yoursite.com
Vary: Origin
Use this for private APIs or anything with auth.
Allow any origin for public read-only data
Access-Control-Allow-Origin: *
Fine for truly public content. Not for credentialed requests.
Allow methods
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Allow request headers
Access-Control-Allow-Headers: Content-Type, Authorization
Cache preflight
Access-Control-Max-Age: 600
This reduces repeated OPTIONS requests.
Expose response headers to JavaScript
Without this, frontend code can only read a limited set of response headers.
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After
For example, GitHub exposes a much larger list:
Access-Control-Expose-Headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
That’s a good real-world example of an API intentionally making metadata available to browser apps.
Reading exposed headers in JavaScript
If your proxy returns:
Access-Control-Expose-Headers: ETag, X-RateLimit-Remaining
ETag: "abc123"
X-RateLimit-Remaining: 42
then browser code can do:
const res = await fetch("https://api.yoursite.com/api/webflow/items");
console.log(res.headers.get("ETag"));
console.log(res.headers.get("X-RateLimit-Remaining"));
Without Access-Control-Expose-Headers, those calls usually return null for non-simple headers.
Common mistakes with Webflow CMS and CORS
1. Putting the API token in frontend code
Don’t.
// bad
const token = "super-secret-token";
That token is public the moment you ship it.
2. Assuming curl success means browser success
This works:
curl -H "Authorization: Bearer TOKEN" https://api.webflow.com/...
But curl does not enforce CORS. The browser does.
3. Forgetting the preflight
If you send Authorization or JSON POST bodies, test the OPTIONS flow too.
4. Using Access-Control-Allow-Origin: * with credentials
If you’re using cookies or credentials: "include", wildcard origin is not valid for that setup.
5. Not exposing headers you need
If your frontend needs ETag, pagination info, rate limit values, or custom metadata, expose them explicitly.
Debugging checklist
When I debug CORS, I check these in order:
- What is the page origin?
- What exact URL is fetch calling?
- Is there an
OPTIONSpreflight? - What does the preflight response contain?
- Does the final response include
Access-Control-Allow-Origin? - Am I trying to read headers that aren’t exposed?
- Am I accidentally doing auth from the browser when I should be proxying?
In Chrome DevTools, open the Network tab and inspect both the OPTIONS request and the actual request. Don’t guess. The headers tell the story.
Best practice for Webflow CMS
If you need to render CMS content on a Webflow site, prefer one of these:
- let Webflow render it server-side into the page
- fetch from your own backend endpoint
- mirror/cache the content into an origin you control
If you need to create, update, publish, or manage CMS items, do it from:
- a backend service
- a serverless function
- an automation worker
- a trusted admin app
That’s not just cleaner for CORS. It’s the only sane way to protect your token.
Minimal production-ready pattern
Frontend:
async function createItem(payload) {
const res = await fetch("/api/webflow/create-item", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Request failed: ${res.status} ${text}`);
}
return res.json();
}
Backend:
app.post("/api/webflow/create-item", express.json(), async (req, res) => {
try {
const webflowRes = await fetch(
"https://api.webflow.com/collections/YOUR_COLLECTION_ID/items",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WEBFLOW_TOKEN}`,
"Content-Type": "application/json",
Accept: "application/json"
},
body: JSON.stringify(req.body)
}
);
const body = await webflowRes.text();
res.status(webflowRes.status);
res.set("Content-Type", webflowRes.headers.get("content-type") || "application/json");
res.send(body);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Webflow proxy error" });
}
});
That pattern avoids the worst Webflow+CORS mistakes and is easy to evolve later with auth, validation, and rate limiting.
If you’re touching other security headers around your API or Webflow frontend, especially CSP, you can cross-check the official docs and, for broader header guidance, CSP Guide.