DEV Community

Me-Time Support
Me-Time Support

Posted on

A Shopify 201 does not mean your title changed

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",
    }},
)
Enter fullscreen mode Exit fullscreen mode

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 } } }
Enter fullscreen mode Exit fullscreen mode

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 } }
}
Enter fullscreen mode Exit fullscreen mode

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="[^"]*"'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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)