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.

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:

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:

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:

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:

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

  1. Bot detection was too naive: Key issues: easily spoofed and potential cloaking issues
  2. Overly strict verification using reverse DNS and forward DNS: this broke legitimate crawler requests.
  3. 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.
  4. 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:

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:

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.

Leave a Reply

Your email address will not be published. Required fields are marked *