Headless CMS preview sounds simple until the browser gets involved.
Your editor clicks “Preview draft”, your frontend tries to fetch unpublished content from a CMS API on another origin, and suddenly the browser throws a CORS error that says almost nothing useful. I’ve seen teams lose hours here because they treated preview like normal production API traffic. It isn’t.
Preview usually combines the hardest parts of cross-origin browser security in one flow:
- a frontend on one origin
- a CMS API on another
- draft content behind authentication
- cookies or bearer tokens
- embedded preview iframes
- local development origins
- origin-specific access rules
If you’re building preview for a headless CMS, you need a CORS policy that’s deliberate, not “just set * and move on”.
Why preview is different from public content
Public content APIs are easy to expose cross-origin. If the data is meant to be public, you can often return:
Access-Control-Allow-Origin: *
That’s roughly what GitHub does for its public API:
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, Warning
That works because the browser is allowed to read the response from any origin, and the data is already public.
Preview content is not public. It usually includes unpublished drafts, internal notes, or content under editorial review. That changes the design completely.
For preview, you usually need all of this:
- allow only specific frontend origins
- allow credentials if using session cookies
- allow auth headers if using tokens
- handle preflight requests
- expose response headers your app actually needs
- avoid accidentally making draft content readable from arbitrary sites
The most common preview architecture
A typical setup looks like this:
- CMS admin:
https://cms.example.com - Preview frontend:
https://preview.example.com - Production frontend:
https://www.example.com - CMS API:
https://api.example-cms.com
An editor updates a draft in the CMS, then the preview app fetches draft content from the API.
That fetch is cross-origin:
- page origin:
https://preview.example.com - API origin:
https://api.example-cms.com
Without the right CORS headers, the browser blocks JavaScript from reading the response.
The first big rule: * and credentials do not mix
If your preview API uses cookies, your fetch probably looks like this:
const res = await fetch("https://api.example-cms.com/preview/posts/123", {
credentials: "include",
});
When credentials: "include" is used, the server cannot reply with:
Access-Control-Allow-Origin: *
It must return the exact requesting origin:
Access-Control-Allow-Origin: https://preview.example.com
Access-Control-Allow-Credentials: true
If you forget this, the browser rejects the response even if the API returned 200 OK.
That’s why “just use wildcard” is a bad preview strategy.
A secure CORS policy for preview
For draft preview, I usually recommend:
- exact origin allowlist
- credentials only if truly needed
- minimal methods
- minimal request headers
- exposed response headers only when needed
Vary: Originwhen dynamically reflecting allowed origins
Here’s a solid Express example.
import express from "express";
const app = express();
const allowedOrigins = new Set([
"https://preview.example.com",
"https://cms.example.com",
"http://localhost:3000",
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && allowedOrigins.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Preview-Token"
);
res.setHeader(
"Access-Control-Expose-Headers",
"ETag, X-Preview-Revision"
);
}
if (req.method === "OPTIONS") {
return res.status(204).end();
}
next();
});
app.get("/preview/posts/:id", (req, res) => {
res.setHeader("ETag", '"draft-rev-42"');
res.setHeader("X-Preview-Revision", "42");
res.json({
id: req.params.id,
title: "Draft title",
body: "Unpublished preview content",
});
});
app.listen(8080);
This is the shape you want for most preview APIs.
Why preflight happens so often in preview flows
A “simple” GET request may avoid preflight. Preview requests often aren’t simple.
You trigger preflight when you use things like:
Authorizationheader- custom headers like
X-Preview-Token Content-Type: application/jsonon some non-simple requests- methods like
PUT,PATCH, orDELETE
The browser first sends:
OPTIONS /preview/posts/123
Origin: https://preview.example.com
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorization,x-preview-token
Your API must answer correctly before the real request is sent:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://preview.example.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Preview-Token
Vary: Origin
If your preview works in Postman but fails in the browser, preflight is usually the reason. Postman doesn’t enforce browser CORS rules.
Cookie-based preview vs token-based preview
I generally prefer avoiding cross-site cookies for preview if I can. Modern cookie behavior is stricter, and debugging cross-site cookie issues is miserable.
Cookie-based preview
Frontend request:
const res = await fetch("https://api.example-cms.com/preview/posts/123", {
credentials: "include",
});
Server response must include:
Access-Control-Allow-Origin: https://preview.example.com
Access-Control-Allow-Credentials: true
And the cookie itself must be usable cross-site, which usually means:
Set-Cookie: preview_session=abc123; Path=/; Secure; HttpOnly; SameSite=None
That works, but now you’re juggling CORS and cookie policy at the same time.
Token-based preview
Sometimes it’s cleaner to mint a short-lived preview token and send it explicitly:
const res = await fetch("https://api.example-cms.com/preview/posts/123", {
headers: {
Authorization: `Bearer ${previewToken}`,
},
});
That avoids cookie headaches, but it triggers preflight because of the Authorization header. Usually that tradeoff is worth it.
Exposing headers your preview app needs
By default, browser JavaScript cannot read every response header. If your preview UI needs metadata from headers, you must expose them.
GitHub’s API is a good real-world example. It exposes a long list of useful headers:
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, Warning
For CMS preview, common headers to expose are:
ETagfor draft cache validationLinkfor pagination or related contentLocationafter creating a preview session- custom revision headers like
X-Preview-Revision
Example:
Access-Control-Expose-Headers: ETag, Link, X-Preview-Revision
Then your frontend can read them:
const res = await fetch("https://api.example-cms.com/preview/posts/123", {
headers: {
Authorization: `Bearer ${previewToken}`,
},
});
const etag = res.headers.get("ETag");
const revision = res.headers.get("X-Preview-Revision");
Without Access-Control-Expose-Headers, those reads return null even though the headers are present.
Handling local development without opening the door too wide
Preview usually needs localhost support. Don’t solve that by allowing every origin.
Bad:
res.setHeader("Access-Control-Allow-Origin", "*");
Better:
const allowedOrigins = new Set([
"https://preview.example.com",
"https://cms.example.com",
"http://localhost:3000",
"http://127.0.0.1:3000",
]);
Be explicit. If your team uses multiple dev ports, list them or generate from config. Don’t reflect arbitrary origins.
This is the unsafe version I still see in rushed codebases:
const origin = req.headers.origin;
res.setHeader("Access-Control-Allow-Origin", origin);
That is basically “allow any website that asks nicely”. For a draft preview API, that’s reckless.
At minimum, validate against an allowlist first.
Preview in an iframe changes the problem
Many CMS platforms render preview inside an iframe embedded in the CMS admin. CORS still matters for API fetches, but now you also need to think about framing and window communication.
If https://cms.example.com embeds https://preview.example.com, then your preview app should usually validate postMessage origins carefully:
window.addEventListener("message", (event) => {
if (event.origin !== "https://cms.example.com") return;
if (event.data?.type === "preview:update") {
console.log("Received draft update", event.data.payload);
}
});
And if you’re working on framing controls beyond CORS, that falls into security headers territory like CSP frame-ancestors, not CORS. If you need a refresher there, the relevant docs are the official CSP docs, and I also like keeping a mental model from https://csp-guide.com.
A practical debugging checklist
When preview fails, I check these in order:
-
What is the page origin?
- Look at the actual frontend origin, not what you think it is.
-
Is the request credentialed?
credentials: "include"changes the CORS rules.
-
Is there a preflight?
- Check for an
OPTIONSrequest in DevTools.
- Check for an
-
Does the preflight response include the requested headers and method?
- Especially
Authorizationand custom preview headers.
- Especially
-
Is
Access-Control-Allow-Originexact, not*, when credentials are involved? -
Is
Vary: Originpresent when the server reflects approved origins?- This matters for caches and CDNs.
-
Are the needed response headers exposed?
ETagand revision headers are common misses.
-
Are cookies actually cross-site compatible?
SameSite=None; Secureis often required.
A good default policy for headless CMS preview
If I had to pick one baseline, it would be:
- allow only known preview and CMS origins
- use short-lived bearer tokens when possible
- support preflight cleanly
- expose only needed headers like
ETag - avoid wildcard origins for anything draft-related
- add
Vary: Origin - keep production public-content CORS separate from preview CORS
Public API CORS and preview API CORS are different jobs. Treat them differently and your preview setup gets much easier to reason about.
That’s really the trick: preview is not “just another frontend request”. It’s a privileged cross-origin workflow, and your CORS policy should reflect that.