Fixing CORS Issues in Headless WordPress: A Complete Guide

You’ve set up your Next.js frontend, pointed it at your WordPress REST API, and immediately hit a wall of red text in the console: Access to fetch at 'https://wp.example.com/wp-json/...' from origin 'https://example.com' has been blocked by CORS policy. Welcome to the most common headless WordPress headache.
Why CORS Happens
CORS (Cross-Origin Resource Sharing) is a browser security feature. When your frontend at example.com makes a request to your WordPress API at wp.example.com, the browser checks if the API server explicitly allows that origin. If WordPress doesn’t send the right headers, the browser blocks the response — even if the server returned data successfully.
Key detail: CORS only applies to browser requests. Your Next.js server components and API routes can fetch from WordPress without any CORS issues because server-to-server requests skip the browser entirely. The problem hits when you fetch from client components or when the browser sends preflight requests.
The WordPress Fix: functions.php
Add CORS headers to your WordPress REST API responses:
// functions.php
add_action('rest_api_init', function () {
remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');
add_filter('rest_pre_serve_request', function ($value) {
$origin = get_http_origin();
$allowed_origins = [
'https://example.com',
'https://www.example.com',
'http://localhost:3000',
];
if (in_array($origin, $allowed_origins, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE');
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce');
}
return $value;
});
}, 15);
Notice I’m checking against a whitelist instead of using *. Using Access-Control-Allow-Origin: * won’t work if you need to send credentials (cookies, auth headers), which you almost certainly do for authenticated requests.
Handling Preflight Requests
Before sending a POST or any request with custom headers, the browser sends an OPTIONS request called a “preflight.” WordPress doesn’t handle OPTIONS requests well by default. Add this early in your theme or plugin:
// Handle preflight OPTIONS requests
add_action('init', function () {
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
$origin = get_http_origin();
$allowed_origins = [
'https://example.com',
'http://localhost:3000',
];
if (in_array($origin, $allowed_origins, true)) {
header('Access-Control-Allow-Origin: ' . $origin);
header('Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE');
header('Access-Control-Allow-Credentials: true');
header('Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce');
header('Access-Control-Max-Age: 86400');
}
status_header(204);
exit;
}
});
The Access-Control-Max-Age header tells the browser to cache the preflight response for 24 hours, reducing the number of OPTIONS requests.
Nginx-Level Fix
If you control your server config, handling CORS at the Nginx level is cleaner and catches requests before PHP even runs:
# nginx site config
location /wp-json/ {
set $cors_origin "";
if ($http_origin ~* "^https?://(example.com|localhost:3000)$") {
set $cors_origin $http_origin;
}
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, X-WP-Nonce' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Max-Age' 86400;
return 204;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
try_files $uri $uri/ /index.php?$args;
}
Debugging Checklist
When CORS still isn’t working after you’ve added headers, run through this:
- Check the actual response headers — open Network tab in DevTools, find the failing request, look at the response headers. Are your CORS headers actually there?
- Look for duplicate headers — if both Nginx and PHP send
Access-Control-Allow-Origin, some browsers reject the response. Pick one layer. - Verify the origin matches exactly —
https://example.comis nothttps://www.example.com. Include both in your allowed list. - Check for plugins overriding headers — security plugins like WordFence or iThemes can strip or override CORS headers.
- Test with curl —
curl -I -H "Origin: https://example.com" https://wp.example.com/wp-json/wp/v2/postsshows you exactly what headers the server returns.
The Server Component Escape Hatch
Here’s the pragmatic solution: do all your WordPress API calls in Next.js server components or route handlers. Server-to-server requests bypass CORS entirely. You only need CORS headers for client-side fetches, which you can often avoid by restructuring your data fetching to happen on the server.
Written by
Adrian Saycon
A developer with a passion for emerging technologies, Adrian Saycon focuses on transforming the latest tech trends into great, functional products.


