Your WordPress site is quietly poisoning its own database — and one day it will cost you a migration.
Every time someone hits Update in the block editor, WordPress takes your domain name and hard-wires it into the content. Not once. Thousands of times. Every image, every link, every button, every embed — the domain is burned into post_content forever.
WordPress doesn't store links. It stores absolute URLs — https://yourdomain.com/… — inside the database.
The block editor is the worst offender. It serializes every block with the full domain baked into the block attributes:
<!-- wp:image {"url":"https://yourdomain.com/wp-content/uploads/2026/08/x.jpg","id":42} -->That yourdomain.com never leaves. It looks harmless. It isn't.
- You move domains, and your site breaks. Every internal link still points at the old domain. Images 404. Redirects fire. SEO collapses — over a string of characters you never meant to keep.
- Staging leaks into production — or production leaks into staging. One wrong push and your live site serves
staging.yourdomain.comlinks to real customers. - Every future migration needs a risky
search-replace. And a search-replace over serialized data is exactly where databases get corrupted. - You are welded to one domain. Previewing from another host, on mobile, or behind a VPN breaks your content.
This isn't a block editor bug. It's 20-year-old architecture.
WordPress was built around one assumption: one site, one fixed URL, stored in the database. Every core function — home_url(), get_permalink(), the media library — returns absolute URLs, because content has to work outside a browser too: RSS feeds, email notifications, the REST API, the mobile apps, oEmbed, sitemaps, canonical and Open Graph tags. In all of those, a href="/page/" is meaningless; only https://domain/page/ resolves.
So, historically, "moving a site" always meant exactly one thing: a risky search-replace across the entire database. The core team has declined to change this for years — it's the price of the one-site model, and you were never the target audience.
The block editor just made it worse: it stores your domain twice — in the visible HTML and inside the JSON of every single block — and re-bakes it on every save.
This plugin breaks the cycle for good.
Once it's active, your domain stops being a storage problem and becomes a runtime one: stripped before anything is written to the database, stripped again before anything is rendered. Move domains whenever you want. The content will never care.
This plugin makes your own URLs root-relative — href="/path/" instead of href="https://yourdomain.com/path/".
A root-relative URL resolves against whatever domain is currently serving the site — never against the domain that happened to be live when someone clicked "Save".
Your domain now lives in exactly one place: your environment (WP_HOME / WP_SITEURL). Not in your content. Not in your database.
- ✅ Store root-relative
- ✅ Render root-relative
- ✅ External links stay untouched
- ✅ No search-replace, ever again
Four layers, one guarantee:
- On save —
content_save_preandrest_pre_insert_poststrip your own domain frompost_contentbefore it reaches the database. What you save is what you can safely move. - On render —
the_content,the_excerptand widget content strip your own domain from the final HTML. Legacy content that already contains absolute URLs is neutralized on the way out — no database migration required. - On feed output —
the_content_feedandthe_excerpt_rssrestore your own root-relative URLs back to absolute. RSS/Atom readers have no site of their own to resolvehref="/path/"against, so feeds get the domain put back, while the database and the normal page render stay root-relative. - On public REST reads —
rest_prepare_{post_type}restorescontent.rendered/excerpt.renderedback to absolute for every REST request exceptcontext=edit(the block editor's own reads). A headless/decoupled frontend, mobile app, or any other third-party REST consumer is in the exact same position as a feed reader: no site origin of its own to resolve a root-relative path against.context=editrequiresedit_postcapability, checked by WordPress core before this filter ever runs — it can't be spoofed by an anonymous or public client, so the block editor keeps working relative while everyone else gets absolute URLs.
Both plain URLs (https://host/path) and JSON-escaped URLs (https:\/\/host\/path, as used inside block attributes) are handled by the save and render layers. External URLs are never touched.
// Input (block markup)
'<!-- wp:image {"url":"https://yourdomain.com/wp-content/uploads/x.jpg","id":42} -->'
// Stored / rendered
'<!-- wp:image {"url":"\/wp-content\/uploads\/x.jpg","id":42} -->'Composer:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/derpixler/make-block-editor-links-relative.git" }
],
"require": {
"derpixler/make-block-editor-links-relative": "^1.0"
}
}The package is type: wordpress-plugin, so with composer/installers it installs
into wp-content/plugins/{name}/. Activate it once (wp-admin → Plugins, or
wp plugin activate make-block-editor-links-relative) — the filters run
immediately, no further configuration.
Manual: copy the plugin directory into wp-content/plugins/ and activate it.
The site's own base URLs are derived automatically from WordPress
(home_url(), site_url(), content_url()) — i.e. from WP_HOME / WP_SITEURL.
Controls the save layer only. When true (default), the site's base URL is
stripped from post_content before it is written to the database — the domain
never gets baked in. The render layer is always active and is not affected by
this filter.
Disable it to run in render-only mode: the database keeps absolute URLs, but the frontend output is still normalized on the fly.
add_filter( 'mbelr_enable_save_normalization', '__return_false' );Use case: You run the plugin on a site where another tool reads post_content
directly and expects absolute URLs (for example an external import pipeline or a
legacy plugin). Keep the database untouched and only fix what visitors actually
see:
// Keep absolute URLs in the DB, normalize the output only.
add_filter( 'mbelr_enable_save_normalization', '__return_false' );With this filter removed, the save layer is enabled and the database itself stays domain-free — that's the recommended mode.
Controls the feed layer only. When true (default), root-relative URLs in
RSS/Atom feed output (the_content_feed, the_excerpt_rss) are restored to
absolute, since a feed reader has no site origin of its own to resolve
href="/path/" against.
add_filter( 'mbelr_enable_feed_absolutization', '__return_false' );Use case: You post-process feed output yourself (e.g. a caching proxy that rewrites URLs per-subscriber) and want the plugin to leave feed content root-relative like everything else.
Controls the REST layer only. When true (default), content.rendered and
excerpt.rendered in public REST responses (any context other than edit)
are restored to absolute, since a headless frontend or other third-party
consumer has no site origin of its own to resolve href="/path/" against. The
block editor's own reads (context=edit) are never touched.
add_filter( 'mbelr_enable_rest_absolutization', '__return_false' );Use case: Your only REST consumer is the block editor (no headless frontend, no external API integration), and you'd rather every representation of your content — page, feed, REST — stay root-relative.
# Unit tests (27 cases, no WordPress required)
composer install
vendor/bin/phpunit
# End-to-end (Playwright + wp-env, requires Docker)
cd tests/e2e
npm install
npx playwright install chromium
npx playwright test # auto-starts WordPress via wp-env (ports 8888/8889)
npm run report # opens the HTML report with traces, video & screenshotsThe end-to-end suite covers all three layers:
| Test | What it proves |
|---|---|
make-links-relative.spec.js |
Render layer: legacy absolute URLs are neutralized on output, DB untouched |
gutenberg-blocks.spec.js |
Save layer: a landing page of link-generating blocks (heading, paragraph, button, image, list) is stored domain-free via the real block-editor REST endpoint |
block-editor.spec.js |
Backend UI: a link typed in the block editor is saved domain-free |
feed-absolute.spec.js |
Feed layer: /feed/ restores root-relative URLs back to absolute in <content:encoded> |
rest-absolute.spec.js |
REST layer: public REST reads (context=view) get absolute URLs, the editor's own context=edit reads stay root-relative |
Does this break SEO? No. Canonical URLs, Open Graph tags and sitemaps are generated from the environment URL, not from the_content, so they're always absolute. RSS/Atom feed content goes through the_content/the_excerpt like everything else, but the feed layer restores it to absolute before it reaches subscribers — see Configuration.
What about external links? They stay exactly as they are.
What about headless frontends / third-party REST consumers? They get absolute URLs. content.rendered and excerpt.rendered are restored to absolute for every REST request except the block editor's own context=edit reads — see Configuration.
Do I still need to run search-replace on old content? No. The render layer neutralizes old absolute URLs on the fly. A one-time search-replace is still fine if you want a spotless database, but you don't need it.
Does it touch the database on install? No. It only changes what gets written going forward.
Do I have to keep it active? Yes — keep it activated on every environment (local, staging, production). That's what guarantees the domain stays out of the content.
GPL-2.0-or-later. See LICENSE.
