Summary: We moved from standard WordPress to a headless React setup and found that Google could render it, but Bing largely could not. The fix was a Cloudflare Worker serving prerendered HTML to Bing via prerender.io. Lesson learned? If you care about Bing, plan for HTML early. First time is not always the charm. Sometimes the 18th time is.
Introduction
When we moved UR Digital’s website from standard WordPress to a headless setup, the plan was simple.
- Keep WordPress as the backend.
- Use React for the frontend.
- Host everything behind Cloudflare.
- Build a faster, cleaner single-page application.
That part worked. The site looked good. It loaded properly for users. The React shell did exactly what it was meant to do.
The SEO problem appeared after launch. Google could render the site. Bing could not. Bing started flagging the same issues across pages:
- Missing title
- Missing description
- Missing heading tag
It was also clear Bing was not seeing the page content properly. Meaning, it was reading the shell, not the finished page.
Find out what caused that, how we fixed it, and what I would recommend if you are planning a headless WordPress build and care about Bing.
The Setup
Originally, the site was standard WordPress. WordPress handled both the CMS and the frontend.
We changed that.
The current stack looks like this:
- Headless WordPress as the CMS
- React as the frontend
- Cloudflare as the hosting and edge layer
- Custom API endpoints
- React Router
The frontend behaves more like an application than a traditional website. That is the point of the setup. Content lives in WordPress, React pulls it in through the API, and the user sees a modern single-page experience.
That is fine for browsers. It is not always fine for crawlers. Browsers execute JavaScript and render content dynamically, but some crawlers don’t.
The Problem
A React single-page application usually starts with a light HTML file and a JavaScript bundle. The real content appears after JavaScript runs.
That means the first response can be very thin.
Something like this:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loading application...</title>
<meta name="description" content="" />
<script type="module" src="/assets/index.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
A browser sees that, runs JavaScript, mounts React, fetches content, and shows the page. A crawler may not. That was the issue.
Google handled our JavaScript well enough. Bing did not. Bing was effectively seeing a page with no usable title, no usable meta description, no headings, and no. That is why the errors showed up.
What the Site Looked Like to Bing
The easiest way to explain it is this:
- Users saw the site.
- Google saw the site.
- Bing saw too little of it to index it properly. Bing could not see any content on the site.
You could verify that by checking page source. In this case, old-fashioned Ctrl + U was useful. What we saw there was much closer to Bing’s version than to the fully rendered browser version.
That is the trap with client-rendered React if you care about SEO. The app works, the design works, and the content exists, but not in the format some crawlers need at the moment they need it.
The WordPress Side
WordPress was not the problem. It was doing its job as a backend CMS.
We were using custom post types and ACF to structure content. A simple example from the homepage was a trusted logos section.
We created a client custom post type for that.
<?php
function urd_register_client_cpt() {
$labels = array(
'name' => 'Clients',
'singular_name' => 'Client',
'menu_name' => 'Clients',
'add_new_item' => 'Add New Client',
'edit_item' => 'Edit Client',
'all_items' => 'All Clients',
'featured_image' => 'Logo',
'set_featured_image' => 'Set logo',
);
$args = array(
'labels' => $labels,
'public' => true,
'show_in_rest' => true,
'has_archive' => false,
'exclude_from_search'=> true,
'supports' => array('title', 'featured-image', 'page-attributes'),
'menu_icon' => 'dashicons-groups',
);
register_post_type('client', $args);
}
add_action('init', 'urd_register_client_cpt');
Then we added ACF fields for things like:
- Website URL
- Show on homepage toggle
- Logo alt text
And exposed them to the REST API.
<?php
function urd_register_client_rest_fields() {
register_rest_field('client', 'client_meta', array(
'get_callback' => function($post_arr) {
return array(
'website_url' => get_field('website_url', $post_arr['id']),
'show_on_homepage' => get_field('show_on_homepage', $post_arr['id']),
'logo_alt' => get_field('logo_alt', $post_arr['id']),
);
},
'schema' => null,
));
}
add_action('rest_api_init', 'urd_register_client_rest_fields');
So, the backend content model was clean. The problem came later, when React had to render it for search engines.
The React Side
On the frontend, React fetched content from WordPress and rendered it normally.
Here is a simplified version of the trusted logos component:
import { useEffect, useState } from "react";
const WP_URL = import.meta.env.VITE_WP_URL;
export default function TrustedByTopTeams() {
const [clients, setClients] = useState([]);
useEffect(() => {
async function fetchClients() {
const response = await fetch(
`${WP_URL}/wp-json/wp/v2/client?per_page=100&orderby=menu_order&order=asc&_embed`
);
const data = await response.json();
const filtered = data
.filter((item) => item.client_meta?.show_on_homepage)
.map((item) => {
const image = item._embedded?.["wp:featuredmedia"]?.[0];
return {
id: item.id,
name: item.title?.rendered || "",
href: item.client_meta?.website_url || "#",
alt: item.client_meta?.logo_alt || item.title?.rendered || "Client logo",
image:
image?.media_details?.sizes?.full?.source_url ||
image?.source_url ||
"",
};
});
setClients(filtered);
}
fetchClients();
}, []);
return (
<section className="trusted-by-top-teams">
<div className="container">
<h2>Trusted by top teams</h2>
<ul className="logo-grid">
{clients.map((client) => (
<li key={client.id}>
<a href={client.href} target="_blank" rel="noopener noreferrer">
<img src={client.image} alt={client.alt} />
</a>
</li>
))}
</ul>
</div>
</section>
);
}
Again, none of this was broken for users. It broke for Bing because Bing was not reliably rendering the JavaScript needed to build the page.
The Fix
Dynamic rendering was the only solution that would work in this case. It allows crawlers to receive a fully rendered HTML whilst the users still receive the normal React app.
Architecture for Crawlers
Crawler –> Cloudflare Worker –> Prerender.io –> Rendered HTML –> Crawler
Architecture for Users
User –> Cloudflare –> React SPA –> Client-side rendering
We put a Cloudflare Worker in front of the site.
The Worker checks incoming traffic. If the visitor is a human, it serves the normal React version. If the visitor is Bingbot, it serves a prerendered HTML version instead.
That way, Bing gets a complete HTML document with a title, description, headings, and body content already present.
Here is a simplified version of that Worker logic:
const BOT_PATTERNS = [
/bingbot/i,
/adidxbot/i,
/bingpreview/i,
];
const SKIP_EXTENSIONS = [
".js", ".css", ".png", ".jpg", ".jpeg", ".gif",
".svg", ".ico", ".webp", ".woff", ".woff2",
".ttf", ".map", ".xml", ".txt",
];
function shouldSkip(url) {
return SKIP_EXTENSIONS.some((ext) => url.pathname.endsWith(ext));
}
function isBingBot(userAgent) {
return BOT_PATTERNS.some((pattern) => pattern.test(userAgent));
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
const userAgent = request.headers.get("user-agent") || "";
if (shouldSkip(url)) {
return fetch(request);
}
if (isBingBot(userAgent)) {
const prerenderEndpoint = `${env.PRERENDER_BASE_URL}?url=${encodeURIComponent(url.toString())}`;
const prerenderResponse = await fetch(prerenderEndpoint, {
headers: {
"x-prerender-token": env.PRERENDER_TOKEN,
"user-agent": userAgent,
},
});
if (prerenderResponse.ok) {
return new Response(prerenderResponse.body, {
status: prerenderResponse.status,
headers: prerenderResponse.headers,
});
}
}
return fetch(request);
},
};
This was the basic pattern.
Serve HTML to Bing. Serve the React app to real users.
The Reality: 17 Failed Attempts
In theory, this should have worked immediately. In practice, it took 17 iterations.
Key Challenges
- Bot detection was too naive: Key issues: easily spoofed and potential cloaking issues
- Overly strict verification using reverse DNS and forward DNS: this broke legitimate crawler requests.
- The Real Bug: HTML Detection. We assumed crawlers send: Accept: text/html, but Bing doesn’t always do that, the result was X-UR-Debug-IsHTML: false. This Worker skipped prerender entirely. The fix, Treat extensionless URLs as HTML pages, regardless of headers.
- Debugging at the Edge: We added debug headers: X-UR-Debug-IsHTML, X-UR-Debug-BypassPath, X-UR-Debug-BypassQuery. This was the breakthrough.
Preventing Cloaking
Showing bots one version and users another is cloaking if the content is materially different.
That was not the goal here.
We were not showing search engines different content to manipulate rankings. We were serving the same page in a format Bing could process.
There is a difference between changing the content and changing the rendering method.
Still, you cannot just say that and hope everyone agrees. You need to keep both versions in sync.
So, we added cache purging on updates. Every time content changed, Cloudflare cache was cleared so the prerendered HTML stayed aligned with the live page.
We ensured compliance by:
1. Identical Content
Prerendered HTML matches React output exactly.
2. Verified Crawlers Only
Only search engine bots receive prerendered HTML.
3. Automatic Cache Purging
Every content update triggers Cloudflare cache purge. Our website purges all cache on each save. A basic WordPress cache purge example looked like this:
<?php
function urd_purge_cloudflare_on_post_update($post_id) {
if (wp_is_post_revision($post_id)) {
return;
}
$post_url = get_permalink($post_id);
$payload = wp_json_encode(array(
'files' => array($post_url),
));
wp_remote_post('https://api.cloudflare.com/client/v4/zones/YOUR_ZONE_ID/purge_cache', array(
'headers' => array(
'Authorization' => 'Bearer YOUR_API_TOKEN',
'Content-Type' => 'application/json',
),
'body' => $payload,
'timeout' => 20,
));
}
add_action('save_post', 'urd_purge_cloudflare_on_post_update');
The Moment It Finally Worked
After multiple iterations, we saw: x-ur-prerender: 1 and x-processed-by-prerender: true
This confirmed crawlers were receiving fully rendered HTML.
That reduced the risk of Bing seeing stale HTML while users saw updated React output.
What This Taught Us
The first lesson: Do not assume JavaScript rendering is equally reliable across search engines, irrespective of what they claim publicly.
Google was fine. Bing was not.
That difference is important if Bing traffic matters to your business, or if you care about visibility across more than one search ecosystem.
The second lesson: If you are building a headless React site, SEO is no longer mostly a CMS problem. It can be a rendering problem, a delivery problem, and sometimes an infrastructure problem.
You have to think about:
- What the initial HTML contains
- What crawlers see before JavaScript runs
- Whether metadata exists in source
- Whether headings exist in source
- Whether the content is visible without hydration
If the answer to those questions is unclear or no, you are taking on risk.
Recommendations
If you are planning a headless website build, and organic search traffic is important to you, I would not default to a pure client-rendered React SPA.
I would use one of these instead:
- Next.js
- Server-side rendering
- Static generation
- A service like Prerender.io
The common idea is the same: return real HTML upfront.
It removes the guesswork for crawlers and saves you from building rescue systems later.
React itself is not the problem. A fully client-rendered SEO-critical site is the problem.
Conclusion
We moved from standard WordPress to headless WordPress with a React frontend because we wanted a faster, more flexible site.
That part was fine.
The problem was that Bing could not reliably render the JavaScript, so it missed titles, descriptions, headings, and page content.
We fixed that by using a Cloudflare Worker to serve prerendered HTML to Bing while keeping the React experience for users and standard Google crawling.
It worked, but it took time, testing, and far more effort than it would have if HTML had been available from the start.
So, my advice is, if you are going headless and care about Bing results, do not assume the crawler will sort it out. Build for HTML output from day one, or use a framework that does it properly for you.
That is cheaper than finding out after launch that one of the major search engines has been reading your site like an empty div.