On a D2C store I work with, I pulled the 12 product pages getting the most search impressions and read their title tags.
Every single one stopped at 69 or 70 characters. Mid-word. The H1 on the page was the same chopped string.
Here's why that hurt more than it looks. Their product names were long and front-loaded: brand words, then claims, then the variant at the very end. Shades, in their case. So the one part of the name that's different between two products is exactly the part the cut removed.
One product had 19 shade pages. In search results they all looked the same.
The culprit
Somewhere in the theme there's a line like this:
{{ product.title | truncate: 70, '' }}
Two things to know about truncate:
- It counts characters, not words. It'll stop in the middle of one.
- The second argument is what it appends. By default that's
..., so at least you can see something was cut. Pass an empty string, like above, and it cuts with no sign at all.
Here's what it does to two different shades of the same (made-up) product:
Velvet Matte Long-Wear Transfer-Proof Liquid Lipstick For All Skin Tones: Deep Berry Rose
Velvet Matte Long-Wear Transfer-Proof Liquid Lipstick For All Skin Tones: Nude Pink
both become:
Velvet Matte Long-Wear Transfer-Proof Liquid Lipstick For All Skin Ton
Finding it in your theme
Pull the theme with Shopify CLI and grep:
shopify theme pull
grep -rn "truncate" layout/ sections/ snippets/ templates/
Look in layout/theme.liquid for the <title> tag and in the main product section for the H1. Card snippets in collection grids often truncate too, but that's a design choice and it doesn't touch your title tag.
The fix
For the H1, just delete the filter. A heading has no length limit. If the design breaks with a long name, fix it in CSS.
For the title tag, delete it too. Google already shortens long titles in results based on pixel width and adds its own ellipsis. Cut it yourself first and you've just thrown away words Google would've read.
If the names really are too long, write a shorter one in the product's Search engine listing field in the admin. That fills page_title in the theme. For a whole catalogue, export the product CSV and fill the SEO Title column in a spreadsheet, then import it back.
And if nobody's going to write hundreds of SEO titles, here's a fallback that keeps the variant. It only kicks in for product pages with a long title that has a : before the variant:
{%- liquid
assign t = page_title
if template.name == 'product' and t.size > 60 and t contains ': '
assign parts = t | split: ': '
assign t = parts.first | truncatewords: 6, '' | append: ': ' | append: parts.last
endif
-%}
<title>{{ t }} | {{ shop.name }}</title>
Same two products after the fix:
Velvet Matte Long-Wear Transfer-Proof Liquid Lipstick: Deep Berry Rose | Example Store
Velvet Matte Long-Wear Transfer-Proof Liquid Lipstick: Nude Pink | Example Store
Short titles, titles without a colon and non-product pages pass through untouched. Hyphenated words count as one word for truncatewords, so "Long-Wear" doesn't get split.
Change the 6 and the : to match how your store names things.
Checking a whole store
I didn't want to read titles by hand again, so here's a script. It reads the product sitemap, gets each product's real name from Shopify's public /products/<handle>.js endpoint, and compares it with the <title> and <h1> on the page.
It only flags a cut that stops mid-word or ends in .... A store that writes its own custom titles, or keeps the colour out of the H1 on purpose, won't get flagged.
"""Find Shopify product pages whose <title> or <h1> drops the end of the product name.
Usage: python3 shopify_title_check.py https://store.example.com 50
"""
import html
import json
import re
import sys
import time
import urllib.request
UA = {"User-Agent": "Mozilla/5.0 (title-check)"}
DELAY = 2 # Shopify answers 429 if you go much faster
MIN_CUT = 40 # truncate filters cut long strings; shorter mismatches are usually singular vs plural
def get(url):
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=20) as r:
return r.read().decode("utf-8", "replace")
def product_urls(store, limit):
index = get(f"{store}/sitemap.xml")
maps = [html.unescape(m) for m in re.findall(r"<loc>([^<]*sitemap_products[^<]*)</loc>", index)]
urls = []
for m in maps:
urls += re.findall(r"<loc>([^<]*/products/[^<]+)</loc>", get(m))
if len(urls) >= limit:
break
return urls[:limit]
def text_of(tag, page):
m = re.search(rf"<{tag}[^>]*>(.*?)</{tag}>", page, re.S | re.I)
return html.unescape(re.sub(r"<[^>]+>|\s+", " ", m.group(1))).strip() if m else ""
def cut_short(shown, real):
"""True if shown is the product name chopped by a truncate filter.
A custom SEO title or a deliberately short H1 isn't flagged. Only a prefix
of the real name that stops mid-word or ends in an ellipsis is.
"""
cores = [shown] + [shown[: m.start()] for m in re.finditer(r"\s[|\u2013\u2014-]\s", shown)]
for core in cores:
ellipsis = core.endswith(("...", "\u2026"))
core = core.rstrip(".\u2026 ")
if core and len(core) < len(real) and real.startswith(core):
mid_word = core[-1].isalnum() and real[len(core)].isalnum()
if ellipsis or (mid_word and len(core) >= MIN_CUT):
return True
return False
def check(url):
real = json.loads(get(f"{url}.js"))["title"].strip()
page = get(url)
title, h1 = text_of("title", page), text_of("h1", page)
problems = [f"{name} cut short: {shown!r}"
for name, shown in (("title", title), ("h1", h1)) if cut_short(shown, real)]
return real, problems
def main():
store, limit = sys.argv[1].rstrip("/"), int(sys.argv[2]) if len(sys.argv) > 2 else 50
bad = 0
for url in product_urls(store, limit):
real, problems = check(url)
if problems:
bad += 1
print(f"\n{url}\n product: {real!r} ({len(real)} chars)")
for p in problems:
print(" " + p)
time.sleep(DELAY)
print(f"\n{bad} of {limit} product pages cut the name short")
if __name__ == "__main__":
main()
python3 shopify_title_check.py https://your-store.com 50
About that 2-second delay: Shopify starts answering 429 if you crawl it at normal crawler speed, and the script dies halfway through. Found that out the hard way.
It's standard library only, so there's nothing to install.
I drafted this with help from an AI assistant. The store story is from my own client work. The Liquid and the script were tested before publishing.
Top comments (0)