A developer on the client side sent over a Google Sheet export as CSV: 33 URLs, each with an approved meta description, for pages that until then had no meta description at all. The request was explicit, upload these meta descriptions first, before anything else. I was holding another batch of fixes for the same site, and I decided the 33 URLs would go in ahead of it.
What I wanted from the start was a method that needed no file upload, no per-page UI, and worked the same way for Pages, every custom post type in the theme, and the podcast post type. Part of the reason came from earlier work on the same site: the Rank Math settings screens turned out to be a React SPA, with field state living in React rather than in DOM inputs, so the only way to drive them from a script was real Playwright clicks, not setting values through JavaScript. If the settings screens were already that way, driving the UI 33 times was not a road I wanted to take.
Rank Math ships its own REST endpoint
What I ended up using was Rank Math's own internal endpoint: POST /wp-json/rankmath/v1/updateMeta. The body is an object with objectID, objectType: 'post', and a meta object holding rank_math_description. The X-WP-Nonce header comes from window.wpApiSettings.nonce, a nonce I already knew from redirect work the day before, and on success the endpoint answers {slug: true}.
All of this runs from the browser inside a live wp-admin session, so the login cookie travels with the request and the nonce is valid.
// run in the browser, inside a logged-in wp-admin session
async function setDescription(objectID, description) {
const res = await fetch('/wp-json/rankmath/v1/updateMeta', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': window.wpApiSettings.nonce,
},
body: JSON.stringify({
objectID,
objectType: 'post',
meta: { rank_math_description: description },
}),
});
return res.json(); // {slug: true} on success
}There is also an updateMetaBulk endpoint next to it. At the time I only noted that it existed and did not use it, so I am not covering its body shape here.
One thing makes this method uniform: objectType: 'post' covers every post type. Pages, the theme's four CPTs, and the podcast post type all go through the same value, with no need to know which post type is being touched.
Resolving post IDs from the public page
The CSV held URLs, not IDs. Instead of hunting each ID through the admin list, I fetched the public page with a cache-buster parameter and read the postid-NNN class off the <body> element.
async function resolvePostId(url) {
const html = await (await fetch(url + '?cb=' + Date.now())).text();
const m = html.match(/postid-(\d+)/);
return m ? Number(m[1]) : null;
}Never retype special characters
The approved meta descriptions contained characters that break easily on the way through a clipboard or a retype: the GBP symbol, em dashes, curly quotes, the TM sign. To get the bytes across intact, I carried the text from CSV to browser through a chain that never touched a keyboard: Import-Csv -Encoding UTF8 in PowerShell, ConvertTo-Json, then base64, and in the browser back out through atob plus TextDecoder.
$rows = Import-Csv .\meta-description.csv -Encoding UTF8
$json = $rows | ConvertTo-Json -Compress
[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json))const rows = JSON.parse(
new TextDecoder().decode(
Uint8Array.from(atob(B64), c => c.charCodeAt(0))
)
);Rank Math handles the HTML encoding itself: & becomes &, < becomes <, while the UTF-8 characters above are left as they are. So what goes to the endpoint is the raw text from the CSV, not a pre-escaped version.
Verifying 33 out of 33
I re-fetched every URL with a cache-buster, pulled the content of <meta name="description">, decoded its entities, and compared it against the CSV copy. The result was 33 of 33 matching, zero mismatches.
One note for anyone checking through the clean canonical URL without a parameter: the change shows up immediately through ?cb, but the canonical URL can lag by up to about an hour because of the Cloudflare HTML TTL. If you check the clean URL and still see the old text, that is cache, not a failed save.
Four URLs that were not in the CSV
A follow-up audit found four URLs still missing and genuinely absent from the client's sheet: two static pages whose meta descriptions were only about 5 and 12 characters long, and two podcast archive URLs with no meta description at all. For those four I decided to use the same updateMeta method, but only once I had approved the copy myself; public SEO copy does not go out without someone signing off on it first. The copy I approved kept the brand's blunt voice.
The next day all four went in. The two static pages went through objectType: 'post' as usual. The two podcast URLs turned out not to be pages at all: judging by the body class on the public page (archive tax-... term-...), both were term archives of a theme taxonomy, not Pages and not the series terms owned by the podcast plugin. Fortunately Rank Math does manage the meta for that theme taxonomy, so updateMeta with objectType: 'term' and objectTypeID set to the taxonomy name surfaced on the frontend without any theme change.
await fetch('/wp-json/rankmath/v1/updateMeta', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': window.wpApiSettings.nonce,
},
body: JSON.stringify({
objectID: termId, // from the term-NNN body class
objectType: 'term',
objectTypeID: 'client_show', // the theme's taxonomy name
meta: { rank_math_description: description },
}),
});I verified all four byte-exact live, this time with credentials: 'omit' so that what gets checked is what a visitor sees without a login session: <meta name="description"> and og:description both matched, GBP symbol and apostrophes intact. What remained on the two term archives was an empty canonical; I logged it as a separate minor issue and did not address it in this piece of work.
After that I went back to the batch I had pushed aside.
Lessons
- Big plugins often ship their own REST. Before building a custom route to write a plugin's meta, check whether the plugin already has a write endpoint.
objectType: 'post'in updateMeta covers every post type at once; the only thing to distinguish is terms, throughobjectType: 'term'andobjectTypeID.- The post ID is in the public page's body class. If you already have the URL, there is no need to look it up through the admin.
- Never retype client-approved text. Carry the bytes through base64 and let the plugin handle the HTML encoding.
- Verify through a cache-busted URL and without credentials. A canonical URL behind a CDN can lag by up to an hour.