Removing ACF as a Runtime Dependency from WordPress
Advanced Custom Fields is one of the most useful plugins in the WordPress ecosystem. It’s also a runtime dependency: if you build your blocks and options pages on top of it, your site can’t render a single page without it being installed and active.
I run the engineering behind Prepa IN, an online platform that helps people across Mexico finish their high-school diploma (preparatoria) remotely. Under the hood it’s a WordPress site — the main school plus a set of employer-sponsored cohorts. At the center is a LearnDash LMS where every lesson is a PDF: a student opens a course topic and reads the material right there. There are over 6,000 of those PDF lessons, and they’re the single most-used thing on the entire platform.
The whole stack had grown deeply coupled to ACF Pro. Every custom block, every options page, and — most importantly — that PDF lesson viewer all went through get_field(). I wanted the dependency gone: no license to renew, no plugin to keep patched, full control over how our content renders, and the part students would actually feel — a PDF reader that works properly on a phone.
This is the story of removing it without losing data and without taking the site down. It went mostly to plan — and the parts that didn’t are the parts worth writing about.
TL;DR
- ACF’s field values live in plain
wp_optionsand post meta. I wrote native helper functions that read the exact same keys, so no data migration was needed — only the block markup and editor registration changed. - I replaced ACF blocks with native dynamic Gutenberg blocks (
register_block_type+ a PHPrender_callback) and rebuilt the PDF block as a self-hosted PDF.js viewer fed by a same-origin proxy. 6,044 PDF lessons moved to the new viewer. - A migration script rewrote
acf/*blocks to my own namespace across the whole network, with backups and a “zero ACF blocks remaining” gate. - I rolled it out in gated stages: deploy → migrate → verify → deactivate → uninstall.
- The hardest bugs weren’t in my code. They were orphan files the host’s deploy never pruned — dead code that had quietly leaned on ACF for years and only fell over the moment I turned ACF off.
Why ACF is sticky
The thing to understand is what ACF actually owns. It’s less than it feels like.
ACF stores the values you enter in completely standard places:
- An options-page field
foo→ a rowoptions_fooinwp_options. - A post field
foo→ post metafoo. - A repeater
gallery→gallery(the row count) plusgallery_0_image,gallery_1_image, and so on.
ACF reads those with get_field() and friends, and it provides the admin UI and the block registration. But the data itself is just options and meta. That’s the whole key to a clean exit: if you can read those keys yourself, you don’t need ACF at runtime at all.
The approach
1. Native field shims
I wrote a small set of helpers that mirror ACF’s read API but go straight to core functions:
function prepain_get_option_field( $name, $default = null ) {
$value = get_option( 'options_' . $name, $default );
return $value;
}
function prepain_get_option_repeater( $name, $subfields ) {
$count = (int) get_option( 'options_' . $name, 0 );
$rows = [];
for ( $i = 0; $i < $count; $i++ ) {
$row = [];
foreach ( $subfields as $sub ) {
$row[ $sub ] = get_option( "options_{$name}_{$i}_{$sub}" );
}
$rows[] = $row;
}
return $rows;
}
Same storage keys, same data, zero ACF. Every get_field() in the theme became a prepain_get_*() call. Because the keys are identical, the existing content kept resolving the moment ACF was gone — nothing to back-fill.
2. Native dynamic blocks
Each ACF block became a native dynamic block: register_block_type() with a PHP render_callback that pulls fields through the shims and includes the same template. acf/banner became prepain/banner, acf/pdf became prepain/pdf, and so on. Server-rendered, no client JS required for the front end.
3. The PDF block, and a CORS problem
The PDF block was the one that mattered most — it is the lesson, 6,000+ times over. The old ACF block dropped the raw CDN URL into an <object>/<iframe>. I wanted a real viewer — page navigation, zoom, reliable mobile rendering — so I rebuilt it with a self-hosted PDF.js canvas viewer.
PDF.js fetches the PDF bytes in JavaScript and paints them onto a <canvas>. That immediately ran into the browser’s same-origin policy: our media is offloaded to a CDN on a different origin, and the CDN doesn’t send permissive CORS headers. A cross-origin fetch would be blocked, and even if it weren’t, drawing cross-origin bytes taints the canvas.
The fix was a same-origin proxy. The block points the viewer at an admin-ajax.php endpoint that:
- Takes an attachment ID and a signed token.
- Verifies the token (an HMAC, so the endpoint isn’t an open proxy).
- Resolves the attachment to its current URL server-side and streams the bytes back.
Because the request is now same-origin, PDF.js renders cleanly. As a bonus, the markup no longer leaks the raw CDN URL, and resolving by attachment ID means it survives CDN or domain changes. PDF.js itself is vendored into the theme — no third-party script tags.
What this changed for students. The old <object>/<iframe> embed leaned on the browser’s built-in PDF handling, which is exactly where it broke down — most of our students study on their phones, and mobile browsers routinely refuse to render a PDF inline in an iframe. The result was a blank frame, a “download to view” dead-end, or a jarring jump into a native viewer that pulled them out of the lesson. The PDF.js canvas renders the same on every device: the first page paints in place, with previous/next navigation, zoom for small print, and a download link as a fallback — no plugin, no app switch, no leaving the lesson. For a platform where reading the PDF is the lesson, that’s not a cosmetic tweak; it’s the core study experience finally working reliably on the devices students actually use. I verified it after the migration on both desktop and a 390px phone viewport: a real lesson rendered a non-blank first page, with zero <object>/<iframe> fallbacks.
4. The Gutenberg editor trap
Server-rendered blocks with no JS registration show up in the editor as the dreaded “This site doesn’t support this block” placeholder. So I registered each one editor-side with ServerSideRender for the preview and save: () => null.
That uncovered a subtle data-loss trap. Gutenberg silently drops any block attribute you don’t declare when it parses a post. My migrated blocks carried open-ended repeater keys (logos_0_logo, logos_1_logo, …) that I couldn’t enumerate ahead of time. The first time an editor opened and saved one of those posts, every undeclared key would vanish.
The fix: nest all field data under a single declared object attribute (data), so one declared attribute protects an arbitrary payload. Easy to miss, expensive to discover in production.
5. The migration script
Finally, a CLI migration that parsed each post’s blocks, renamed acf/* to prepain/* (and converted a handful of long-dead legacy block types to static core/html), and wrote the result back. It had:
- JSONL backups of every post it touched.
- Network mode to walk every subsite.
- A zero-ACF gate that refused to report success if any unconvertible
acf/*block remained. - A rollback mode.
On the production run it rewrote 6,151 posts across the network — 6,044 PDF lessons converted to the native viewer, plus the assorted banners, heroes, and feature grids — and left zero acf/* comments behind.
The rollout
I treated this as a one-way door and gated every step:
- Deploy the ACF-free theme.
- Migrate the content immediately.
- Verify in the browser.
- Deactivate ACF.
- Uninstall ACF and remove its leftovers.
Why the order matters: the moment the new theme deploys, it stops registering acf/* blocks. Any post still in the old format renders blank until the migration converts it. The host’s full-page cache shielded logged-out visitors, but logged-in users — our students, mid-lesson — bypass that cache, so the window between deploy and migrate is real. The move is to migrate fast, right behind the deploy.
For verification I didn’t trust myself. I:
- Re-ran the zero-ACF gate and cross-checked with raw
SELECT COUNT(*) ... LIKE '%wp:acf/%'per site. - Pre-flighted the proxy by fetching real offloaded PDFs server-side to confirm they came back as
%PDFbefore I committed to the migration. - Logged in as a throwaway, non-admin student and loaded an actual gated lesson to confirm the canvas rendered a real page — desktop and mobile — with no
<object>/<iframe>and no console errors.
Only after all of that did I deactivate ACF. And only after the site sat stable did I delete the plugin — with wp plugin delete rather than wp plugin uninstall, specifically so the uninstall hook couldn’t purge the options that now are the data.
The war stories
Here’s the part I actually want you to remember.
Deactivating a dependency surfaces everything that secretly needed it
My static analysis said the theme was clean: no bare get_field() calls anywhere in the committed code. True. I deactivated ACF, swept the public pages — and one partner subsite’s homepage threw a 500.
The cause: months earlier, someone had “removed dead page templates” in a cleanup commit. But my host’s deploy uses an rsync without --delete — it adds and updates files, it never removes them. So those “deleted” templates had been sitting on the server the entire time. One of them was still assigned to that subsite’s homepage via _wp_page_template, and it called get_field(). While ACF was active, it worked fine. The instant I deactivated ACF, get_field() was undefined and the page fataled.
The lesson is bigger than one file: turning off a dependency is the moment every orphan that quietly relied on it comes due. Static grep can’t see a template the database points at.
The recovery was the easy part — reactivating ACF is one command and a few seconds — so I rolled back immediately, reassigned the orphaned pages to a live template, then deactivated again, clean. Roll back code and data together; never panic forward.
”Not in git” does not mean “safe to delete”
Once ACF was off, I wanted to prune all those orphan files. The obvious approach: diff the server against git ls-files, delete whatever’s only on the server.
That would have deleted the site’s fonts. The web fonts and favicons were untracked in git but very much live — referenced by the compiled CSS at runtime. “Untracked” and “unused” are different questions. I narrowed the deletion to dead PHP that nothing referenced and no page pointed at, backed it up, and verified every site stayed up afterward. Same logic later let me prune only the superseded font versions while keeping the one the build actually loads.
Empirical beats static, every time
The grep was right that the source was clean. It was useless for catching a page template the database referenced, or a font pulled in by a preload tag. I only found the real problems by loading the pages — logged out and logged in — and tracing the actual runtime. On a migration like this, treat your static analysis as a hypothesis and the running site as the truth.
Back up before every irreversible step
Every deletion in this project — migrated posts, orphan files, old fonts, the ACF plugin itself — was preceded by a local backup I could restore from. None of them were needed. All of them were worth it. The plugin backup especially: ACF Pro is licensed, and “we’ll just reinstall it” is a bad plan to discover mid-incident.
The result
ACF is completely gone — deactivated, uninstalled, and its acf-json exports removed from both the repo and the server. The theme renders every block and every options-driven feature through native code and core APIs alone. Field data never moved.
The numbers: 6,044 PDF lessons moved to the new native viewer, 6,151 posts rewritten, zero residual ACF blocks — and the only outage was a single partner homepage I caught and fixed in minutes. The licensing and patching liability is gone, and Prepa IN’s students get a lesson reader that finally behaves the same on a phone as it does on a laptop — which, for a platform where the PDF is the lesson, is the part that actually matters.
If you’re considering the same move: the data side is easier than it looks, the rendering side is a satisfying weekend of work — and the genuinely hard part is everything your dependency was quietly holding up that you won’t see until you let go.
Frequently asked questions
Can you remove ACF without losing the field data?
Yes. ACF stores field values in standard WordPress storage — options as 'options_<name>' in wp_options, post meta as '<name>', and repeaters as '<name>_<index>_<subfield>'. Native helpers can read those exact keys with core get_option()/get_post_meta(), so no value migration is needed. Only the block markup (acf/* → your own namespace) and the editor registration have to change.
What happens to ACF blocks when you deactivate the plugin?
If the theme no longer registers them, an unregistered acf/* block has no renderer and outputs nothing — it renders blank. That's why you migrate the block markup to a native namespace before deactivating, and why deactivating can surface orphaned code paths that quietly depended on ACF being active.
Did removing ACF change anything for students?
Yes, for the better. Prepa IN's lessons are PDFs — over 6,000 of them — and they used to be embedded with an <object>/<iframe> that relied on the browser's built-in PDF support, which often failed on mobile. The replacement is a self-hosted PDF.js canvas viewer that renders consistently on phones and laptops, with page navigation, zoom, and a download fallback, without ever leaving the lesson.
Should you use 'wp plugin uninstall' or 'wp plugin delete'?
Use 'wp plugin delete' to remove the files only. 'wp plugin uninstall' runs the plugin's uninstall hook, which can purge data. Since the field values are plain options/post meta you want to keep, delete is the safer choice.