Shopify stores can serve the same page at /pages/foo, /en/pages/foo and /nl/pages/foo. If you have ever written a script that updates page titles across such a store, you have probably hit the bug I am about to describe. It cost me two weeks of believing work was done when it was not.
The setup
I had a script that rewrote SEO titles through the Admin REST API:
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": new_title, "type": "single_line_text_field",
}},
)
Every call returned 201. I ran it over a hundred pages, logged the successes, and moved on.
Two weeks later I checked the live HTML. Roughly half the pages still showed their old titles.
Why: two layers, one wins silently
The metafield write is real. It updates the primary locale. But if a translation already exists for meta_title on that resource, the translated value wins at render time on /en/ and /nl/ URLs.
Nothing warns you. The API did exactly what you asked — you asked the wrong layer.
Translations live in GraphQL, and the mutation requires a digest of the source value:
{ translatableResource(resourceId: "gid://shopify/Page/730209845580") {
translatableContent { key digest } } }
Request { key digest } and not value. Asking for value drags the whole body_html down with it; batching six resources that way returned 62,000 characters for me.
Then:
mutation {
translationsRegister(
resourceId: "gid://shopify/Page/730209845580"
translations: [{
key: "meta_title", locale: "en", value: "…",
translatableContentDigest: "0853eaa6b9…c4ed0"
}]
) { userErrors { field message } }
}
Read userErrors every time. A stale digest returns HTTP 200 with the failure buried in the payload.
Three traps around it
Translated handles. /en/pages/ginger-and-sleep-does-ginger-help-you-sleep-better may be the English translation of a French page whose handle is completely different. Looking it up by the URL slug returns nothing. 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. Take its handle, look that up, done. I spent an hour grepping handles by keyword before noticing this.
Redirects. Old URLs 301 to the new localized handle. Without curl -L you get an empty body and conclude the page is broken. It is not — and I reported a "broken page" count that was pure artefact before I caught it.
CDN caching. A successful write can still serve the old title for minutes. Append ?v=$(date +%s%N) when verifying, or you will "fix" something twice.
The rule I now apply everywhere
A 200 means stored, not rendered. Any write into a CMS with a translation, theme, or app override layer needs a render-level check — fetch the live page and assert the value is actually there.
Cheap version, and it catches everything above:
curl -sL -A "Mozilla/5.0" "$URL?v=$(date +%s%N)" \
| tr -d '\n' | grep -o '<title>[^<]*' | grep -q "$EXPECTED" \
|| echo "STALE: $URL"
I now treat an unverified write as an unfinished one. It turns a silent half-failure into a one-line assertion.
The store is INTI, a Belgian organic ginger elixir with content in three locales, which is exactly how I walked into all of this.
Has your platform bitten you with a similar silent override? I would like to know which layer won.
Top comments (0)