Make Block Editor Links Relative
WordPress plugin that stores and renders your site's own URLs as root-relative paths, so the domain is never baked into content.
by derpixler · github.com/derpixler/make-block-editor-links-relative · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/derpixler/make-block-editor-links-relative/archive/refs/heads/main.zipReadme
Make Block Editor Links Relative

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.
The problem you don't know you have
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.
Why this will hurt you
- 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.
Why WordPress does this — and why it won't stop on its own
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.
The fix: root-relative URLs
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
How it works
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} -->'
Install
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.
Configuration
The site's own base URLs are derived automatically from WordPress
(home_url(), site_url(), content_url()) — i.e. from WP_HOME / WP_SITEURL.
mbelr_enable_save_normalization
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.
mbelr_enable_feed_absolutization
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.
mbelr_enable_rest_absolutization
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.
Testing
# 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 & screenshots
The 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 |
FAQ
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.
License
GPL-2.0-or-later. See LICENSE.