If you run a multilingual Shopify store and you have ever fixed a page title through the Admin API, only to watch Google keep showing the old one, this post is for you.
I hit this on a store with roughly 2,000 content URLs across French, English and Dutch. I rewrote the SEO titles of 112 pages through the REST API, verified every write returned 200, and moved on. Two weeks later I checked the live HTML. 51 of them had never changed.
Here is what was going on, and how to detect it in one line.
The trap: locale translations outrank metafields
On Shopify, a page's SEO title lives in a metafield:
namespace: global
key: title_tag
Writing it through REST is straightforward:
requests.post(
f"https://{SHOP}/admin/api/2024-01/pages/{page_id}/metafields.json",
headers={"X-Shopify-Access-Token": TOKEN},
json={"metafield": {
"namespace": "global", "key": "title_tag",
"value": "My new title", "type": "single_line_text_field",
}},
)
That write succeeds. It updates the primary locale.
But if your store serves /en/ or /nl/ URLs and a translation already exists for meta_title on that resource, the translated value wins at render time. Your metafield write is real, stored, and completely invisible on the URL that actually ranks.
Worse, it fails silently. No error, no warning. The API is doing exactly what you asked; you just asked the wrong layer.
Detecting it in one line
Give every title you write a stable suffix. I use | Brand. Then the audit is trivial:
curl -sL -A "Mozilla/5.0" "$URL" \
| tr -d '\n' | grep -o '<title>[^<]*' | grep -q '| Brand' \
|| echo "STALE: $URL"
Two details that cost me time:
Use -L. Old URLs frequently 301 to a localized handle (/en/pages/gingembre-sommeil → /en/pages/ginger-and-sleep). Without -L you get an empty body and conclude the page is broken. It isn't.
Cache-bust. Append ?v=$(date +%s%N). The Shopify CDN will happily serve you a stale title for minutes after a successful write, which makes you doubt a fix that already worked.
The fix: translationsRegister
Translations are GraphQL-only. Read the digest first — it is a hash of the source value and the mutation is rejected without a matching one:
{
translatableResource(resourceId: "gid://shopify/Page/730209845580") {
translatableContent { key digest }
}
}
Request { key digest } and not value. Asking for value pulls the entire body_html down with it; batching six resources that way returned 62,000 characters and blew my response budget.
Then write:
mutation {
translationsRegister(
resourceId: "gid://shopify/Page/730209845580"
translations: [{
key: "meta_title"
locale: "en"
value: "Does Ginger Help With Sleep? What the Studies Say"
translatableContentDigest: "0853eaa6b9...c4ed0"
}]
) { userErrors { field message } }
}
Always read userErrors. A stale digest returns 200 with the error buried in the payload.
Bonus: resolving a translated handle to its source
Localized handles break every lookup, because /en/pages/ginger-and-sleep-does-ginger-help-you-sleep-better does not exist as a handle in the default locale. You cannot query it by name.
I wasted an hour grepping handles by keyword before noticing the page tells you itself:
curl -sL "$EN_URL" | tr -d '\n' \
| grep -o 'hreflang="fr" href="[^"]*"'
The hreflang alternate points straight at the source URL. Take its handle, look that up through REST, and you have your resource ID. Reliable, and about forty times faster than guessing.
Two things worth internalising
A 200 means "stored", not "rendered". Any write to a CMS with a translation layer needs a render-level check, not just a status code. I now treat an unverified write as an unfinished one.
Silent overrides are the expensive bugs. Nothing in the REST response hints that another layer will win. If your platform has locale, theme or app-level overrides, find out which one has priority before you run a batch job across a thousand records.
The store behind this write-up is INTI, an organic ginger, turmeric and lemon elixir made in Belgium — a genuinely multilingual Shopify catalogue, which is precisely how I ran into all of this. If you want to see the pattern in the wild, the page from the mutation above is ginger and sleep.
Have you hit a silent override like this on another platform? I would like to hear which layer won.
Top comments (0)