A client site had a set of long-form pages built entirely in Elementor. The first page I opened contained 176 separate widgets: a heading widget here, a paragraph widget there, a spacer, another paragraph, all the way down. The task sounded trivial: replace the entire content of the page with new copy. The catch: there were 34 pages like this, and swapping one page manually in the Elementor editor takes hours. Click a widget, clear it, paste, click the next one, repeat a few hundred times. Multiply that by 34 pages and it stops being work and starts being punishment.
My plan: skip the UI entirely. Elementor stores the whole page layout as one big JSON string in a post meta field called _elementor_data. If I could read and write that meta over the REST API, a full page swap becomes one request instead of hundreds of clicks.
The obvious route dead-ends
The natural first step: expose the meta to REST the official way. WordPress ships register_post_meta() with a show_in_rest argument, so I registered _elementor_data for the custom post type these pages live on.
register_post_meta('client_page', '_elementor_data', [
'show_in_rest' => true,
'single' => true,
'type' => 'string',
]);The result: the GET request to the CPT endpoint worked, the meta field showed up in the response, but it was empty. No error, no permission message, just an empty {} politely claiming the page had nothing in it. Meanwhile the same page opened in the Elementor editor with all 176 widgets intact.
I suspected all the wrong things first: wrong post type name, wrong registration arguments, maybe Elementor storing its data somewhere unexpected. I checked every one of them. The data sat right there in wp_postmeta, the registration ran fine, and REST still returned nothing.
Root cause: underscore meta is protected
The answer was hiding in the very first character of the meta key. In WordPress, any meta whose key starts with an underscore is treated as protected. The REST API refuses to expose protected meta on custom post types, and show_in_rest on register_post_meta() does not override that decision. _elementor_data starts with an underscore, so it gets silently filtered out of the response. No warning, no log line, just {}.
And the alternative path, editing widget by widget in the UI, was ruled out from the start: it makes no sense across 34 pages.
The fix: a custom route with backup and cache busting
If the built-in REST door refuses to touch protected meta, you build your own door. I wrote a custom route, af/v1/edata/{id}, with three methods and a contract I fully control:
- GET: return the raw
_elementor_datafrom post meta, untouched. - POST: write the new JSON, with an automatic backup before overwriting.
- PUT: restore from the backup if the swap goes sideways.
The core of the POST callback:
$current = get_post_meta($id, '_elementor_data', true);
update_post_meta($id, '_elementor_data_afbackup', wp_slash($current));
update_post_meta($id, '_elementor_data', wp_slash($body['data']));
delete_post_meta($id, '_elementor_css');Two details here are load-bearing. First, wp_slash(). update_post_meta() unslashes incoming values, and Elementor's JSON is full of escaped quotes. Without wp_slash(), the backslashes inside the JSON get stripped on save and the layout corrupts. Second, delete_post_meta($id, '_elementor_css'). Elementor caches the generated CSS for each post in that meta. If you do not delete it every time _elementor_data changes, the new content renders with stale styles. Deleting it forces Elementor to regenerate the CSS for that single post.
One more decision made the whole thing sane long-term: I did not split the new content back into hundreds of widgets. The page body got consolidated into 1 to 3 text-editor widgets plus one small snippet of CSS tokens. The page that used to hold 176 widgets came down to 35. The new copy itself was pulled from a Google Doc through its export URL, /export?format=html, so the writers kept working comfortably in Docs and I just fetched the HTML. The end state: one atomic POST per page, and with the automatic backup plus the PUT restore, the whole operation stayed reversible.
Takeaways
- Underscore-prefixed meta is protected.
show_in_restonregister_post_meta()will not expose it for a CPT, so do not burn time debugging that path. - An empty response with no error is the classic signature of data being filtered, not data being missing. Confirm the data actually exists in the database before suspecting everything else.
- Always
wp_slash()before writing JSON into post meta, and delete_elementor_cssevery time_elementor_datachanges. - Bulk operations that overwrite data must be reversible. An automatic backup before the write is not a nice-to-have, it is a requirement.
- If the content structure is what makes editing painful, 176 widgets for one page, fix the structure while you are in there. Do not just automate the pain.
