A domain swap on an editorial site I ported to WordPress looked like it went off without a hitch. I ran Better Search Replace, swapped https://old-site.com/ for the new domain, hit "replace all", and the plugin reported thousands of changes. Done, I thought. Then I clicked around a few pages and half the internal links still pointed at the old domain. Not random ones either — a clean pattern: links inside ordinary paragraphs worked, but links inside certain Gutenberg blocks did not. Buttons, galleries, blocks with attributes — all still pointing at old-site.com.
The thing that nearly fooled me: Better Search Replace said it succeeded. No error, no red line. The search-replace genuinely ran. The problem was that it never found what I assumed it had found.
Here's roughly the trigger. This is the string I searched for, and this is the form actually stored in the database for Gutenberg content:
What I searched for:
https://old-site.com/
What's stored in the Gutenberg block markup:
<!-- wp:button -->
... "url":"https:\/\/old-site.com\/contact" ...
<!-- /wp:button -->Look at the slashes. In a normal paragraph the URL is literal: https://old-site.com/. But inside a block attribute, every forward slash is escaped to \/, so the same URL is stored as https:\/\/old-site.com\/. My search for the plain form was never going to match that escaped form.
Why this happens
Gutenberg stores block attributes as JSON embedded inside HTML comments in post_content. And in JSON, a forward slash may — optionally — be escaped as \/. WordPress (via PHP's wp_json_encode / json_encode) does exactly this by default. So a URL you typed in the editor as https://old-site.com/contact ends up in the database as https:\/\/old-site.com\/contact the moment it lands in a block attribute like wp:button, wp:image, or anything that holds an href in its JSON.
Better Search Replace is raw string matching against the contents of database columns. It doesn't parse JSON, it doesn't unescape anything, it doesn't "know" that https:\/\/old-site.com and https://old-site.com are the same URL. To it they're two different strings. So when I searched only for the plain form, it dutifully replaced every occurrence of the plain form — paragraphs, classic links, unserialized custom fields — and silently skipped every escaped-slash URL inside a block. Half the site updated, half didn't, and the "success" report stayed green because it technically succeeded for what it did match.
This isn't a Gutenberg-only quirk. Any serialized or JSON data — block attributes, widget data in wp_options, serialized meta arrays — stores slashes as \/. The same pattern will bite you wherever it lives.
The fix
Run the search-replace twice: once for the plain form, once for the escaped form. Concretely, these two pairs:
# Pass 1 — plain form (paragraphs, classic links, etc.)
Search: https://old-site.com
Replace: https://new-site.com
# Pass 2 — escaped form (Gutenberg block attributes, widget data, JSON)
Search: https:\/\/old-site.com
Replace: https:\/\/new-site.comThat second pass is what touches the Gutenberg blocks. You search for the literal escaped slashes and replace them with escaped slashes too, so the JSON stays valid afterward — you never mix the two forms inside a single attribute.
One trap when your domain is collision-prone — say one URL is a substring of a longer one, or you have both old-site.com and blog.old-site.com. Naive string matching will corrupt the longer URL. Anchor the search. I add a trailing quote or use the full href="..." context so only the exact match is hit:
# Anchored so it doesn't touch longer sub-paths or subdomains
Search: href="https://old-site.com"
Replace: href="https://new-site.com"Two more things saved me from guesswork. First, always do a dry run first. Better Search Replace has a "dry run" mode that counts without writing to the database — I used it on every pass before letting it run for real. Second, watch what the plugin reports: it reports the number of CHANGES, not the number of matches. So the way to confirm completeness is to re-run the same pass until it reports 0 changes. Zero changes means nothing of that form is left. As long as the number isn't zero, there's still something you missed.
After the escaped pass, I re-ran it, got a big number, ran it again until it hit zero, then checked the pages that had been broken. The button and gallery links finally pointed at the new domain.
The takeaway
A URL migration is not one find-replace — it's at least two, because serialized and JSON content (Gutenberg blocks, widget data, serialized meta) stores slashes as \/. If you only replace the plain form, you silently leave half your links behind, and the "success" report will hide it. Always run both the raw form and the escaped \/ form, anchor collision-prone searches with the href="..." context, start with a dry run, and re-run until the plugin reports zero changes — because what it counts is changes, not matches. Green doesn't mean done; zero means done.
