Technical SEO Audit · coy.co.nz

Two big things already right.
The real problem was pagination.

coy.co.nz measured against the 2026 Shopify technical playbook across schema, internal linking, indexation, Core Web Vitals and E-E-A-T.

21 August 2026 Method: live HTTP + Playwright mobile Pixel 5 · 4× CPU · ~1.6 Mbps 12 findings No GSC or theme-file access

The verdict

Coy is already past the two failure modes that sink most Shopify stores. The collection within: filter is gone — zero /collections/x/products/y URLs anywhere on the site. And there is exactly one schema source: custom Liquid, no microdata, no app-injected duplicate Product block. Those are the two hardest things to fix retrospectively, and they're both clean.

What's left is narrower than the first draft of this page claimed: a duplicate-content pattern repeating across 93 paginated URLs, a set of entity and E-E-A-T gaps in the schema layer, and a handful of indexation loose ends worth closing before traffic arrives.

1.1s Product LCP @ 5 Mbps

Passing. The 4.3–5.7s figure was a 1.6 Mbps artefact — see P1.

0.000 CLS

0.000–0.005 sitewide. Excellent.

0 Duplicate schema

One source. No app conflicts, no microdata.

93 Duplicated paginated URLs

29 of 109 collections. Same six blocks on every one.

Section 01

Already correct — don't touch

Verified live this session. Worth stating explicitly, because several of these are the exact items a generic Shopify audit would flag as broken by default.

within: filter removed 0 collection-scoped product links on home, collection or product templates. The playbook's single highest-leverage fix — already shipped.
myshopify duplication killed pidjfr-f5.myshopify.com301coy.co.nz.
Pagination self-canonicalises ?page=2 canonicals to ?page=2. Not canonicalised back to page 1 — the most common Shopify pagination mistake.
?page=1 cleanup handled Canonicals to the clean collection URL. The one legitimate exception, done right.
Paginated pages not noindexed No meta robots anywhere. Long-term noindex becomes nofollow and orphans deep products — avoided.
?page= not robots-blocked Googlebot can reach page 13 of a collection. Robots is used for crawl waste only, as intended.
robots.txt extended, not replaced Shopify's full default group is intact with three appended rules. Platform updates still flow through.
One schema source Custom Liquid only. itemtype count = 0, no app Product block, no duplicates for Google to ignore.
Merchant-listing fields present sku, gtin, brand, itemCondition, priceValidUntil, two-tier shippingDetails and hasMerchantReturnPolicy at both Organization and Offer level. Above baseline.
No fabricated AggregateRating Coyscore is modelled as additionalProperty, not aggregateRating. The structured-data manual-action risk is correctly avoided.
Lean app stack 4 external script hosts, 393–475 KB JS. No accumulated app bloat, no obvious leftover snippets.
Breadcrumbs + Article schema BreadcrumbList on products and collections; Article with datePublished, dateModified, author, publisher on blog posts. Real 404s on missing products.

Section 02

What I found

Twelve findings, ranked. Everything below was measured or fetched live — no inference from theme conventions.

P1 · Tested and rejected

Product LCP: the fix was built, measured, and reverted — the premise didn't hold

This finding is a correction. The first version of this page called product LCP the site's one critical failure and prescribed a head-level image preload. I built it, deployed it to a preview theme, measured it, and it made LCP worse. Both the diagnosis and the remedy are withdrawn below.

What the preload actually did

Pixel 5 · 4x CPU · median of 3 runs
/products/platinum-premium-silicone-the-minis-smooth-small

  1.6 Mbps    no preload 3652ms    preload 3660ms    +8ms   (noise)
  5   Mbps    no preload 1088ms    preload 1340ms    +252ms (3/3 runs slower)

The hero image already carried fetchpriority="high" and was never lazy-loaded — so the browser was not discovering it late. It was bandwidth-bound. Preloading pulled the image forward into contention with render-blocking CSS and pushed the paint back. Shopify's own performance guidance says exactly this about over-preloading; I should have tested before prescribing.

And the original number was a throttle artefact

1.6 Mbps / 4x CPU   LCP 3.7 – 5.7s   ← the figure this page first reported
5   Mbps / 4x CPU   LCP ~1.1s       ← comfortably inside the 2.5s threshold

1.6 Mbps with a 4× CPU penalty is a deliberately punishing profile, well below typical NZ mobile. At a more representative 5 Mbps the same page passes with room to spare.

Status: no action. The preload has been reverted. The reasoning and the measurements are recorded in a comment in layout/theme.liquid so nobody re-adds it from first principles.

What remains genuinely unknown: the origin still has no CrUX field data — traffic is below the sampling threshold — so real-world LCP is unmeasured, not proven good. Revisit when field data exists, and let that data pick the fix.

P2 · Critical

Six content blocks repeat verbatim across 93 paginated URLs — but the fix is not a canonical

Evidence — fetched pages 1, 2, 3 and 13 and diffed them

/collections/vibrators   386 products · 32 per page · 13 paginated URLs

                        page 1   page 2   page 3   page 13
  intro lede               ✓        ✓        ✓        ✓
  intro paragraph          ✓        ✓        ✓        ✓
  shop-by-type chips       ✓        ✓        ✓        ✓
  Coyscore explainer       ✓        ✓        ✓        ✓
  FAQ (visible, ×2)        ✓        ✓        ✓        ✓
  FAQPage schema           ✓        ✓        ✓        ✓
  buying guide (3+3 img)   ✓        ✓        ✓        ✓
  keep-exploring           ✓        ✓        ✓        ✓
  meta description         ✓        ✓        ✓        ✓   byte-identical

Only the 32-product grid and the "– Page N" title suffix differ.

Across the catalogue:  29 of 109 collections paginate
                       93 paginated URLs beyond page 1
                       deepest: vibrators 13 · anal-toys 11 · bondage 10 · lubricants 10

The diagnosis is right: thirteen near-identical URLs where only the grid changes is a genuine thin/duplicate signal, and it dilutes the one page that should be ranking for the category term.

"So shouldn't page 2 canonical to page 1?" — four reasons not to

1. It doesn't remove the duplicate content — which is the actual complaint. rel=canonical is an index-selection hint. It doesn't delete a block, stop a crawl, or change a byte of what renders. Even honoured perfectly, the intro, FAQ and buying guide still ship on all 93 URLs and still get crawled. You'd have added a directive and changed nothing about the thing that bothered you.

2. It probably won't be honoured. Canonical is a hint, and Google's own content comparison overrides it when the pages genuinely differ — and page 2 has 32 different products. The likely outcome is a no-op.

3. If it is honoured, here is the measured cost. Across 104 collections and 1,788 distinct products, 279 products (15.6%) appear only on page 2 or deeper in every collection they belong to. Those lose their only collection-level internal link. They stay in the XML sitemap so they don't vanish — but sitemap-only discovery is a slower, weaker refresh signal, and it lands on the products already sitting deepest in the catalogue.

4. The alternative is cheaper and strictly better. One Liquid conditional in two sections removes the duplication at source, keeps all 93 URLs crawlable, keeps every signal agreeing, and requires no bet on how Google reads a hint. There is no scenario where the canonical wins and the gate loses.

The competitor precedent — checked, and it argues the other way

peachesandcream.co.nz/collections/anal-toys?page=2
  canonical      → /collections/anal-toys        (canonicalised to page 1)
  intro copy     → still present on page 2
  meta desc      → identical to page 1
  visible words  → page 1: 7,561   page 2: 7,596

Coy for comparison   page 1: 2,835   page 2: 2,741

Peaches and Cream have the same duplication Coy has, and the canonical did not fix it — their page 2 still carries the repeated intro and a byte-identical meta description. They didn't solve the problem; they asked Google to look away from the URL hosting it. That's the clearest available demonstration of reason 1.

They're also a far larger, older domain, which absorbs technical debt Coy can't. Copying the tactic without the authority copies the cost and not the cushion.

The fix — gate the repeated blocks on page 1

{%- unless current_page == 1 -%}
  {%- comment -%} page 2+: grid only {%- endcomment -%}
{%- else -%}
  … intro lede · intro paragraph · shop-by-type chips
  … Coyscore explainer · FAQ + FAQPage schema
  … buying guide · keep-exploring
{%- endunless -%}

Keep on every page: breadcrumb, H1, the product grid, pagination nav, global header and footer. That's exactly what a paginated listing page should be — a grid with a grid's worth of content is not "thin".

current_page is a global Liquid object (value 1 for non-paginated resources), so this works inside the existing coy-collection-header and coy-collection-footer sections without threading paginate through. Verified against Shopify's Liquid reference.

Pagination stays crawlable, indexable and self-canonicalising. Title already appends "– Page N"; add the same suffix to the meta description while you're in there.

Found while checking this — unbounded soft-404 pagination

?page=11   → 200 · 32 products
?page=12   → 200 · 32 products
?page=13   → 200 ·  2 products   ← real last page
?page=50   → 200 ·  0 products · self-canonical
?page=999  → 200 ·  0 products · self-canonical

Every page number past the end returns a 200 with an empty grid and a self-referencing canonical — an unlimited crawlable surface of near-empty pages, on every one of the 109 collections.

Fix: emit noindex when a paginated page has an empty grid.

{%- if collection.products.size == 0 and current_page > 1 -%}
  <meta name="robots" content="noindex">
{%- endif -%}

Safe here: the canonical on those URLs is self-referencing, so this is the harmless noindex-plus-canonical combination, not the contradictory one that forwards a noindex to a different target.

P3 · High

Collection pages ship 2.3 MB of images with 32 eager <img> tags

Evidence

/collections/vibrators
  <img> tags:        55
  loading="eager":   32
  image transfer:    2,323 KB
  load event:        20.2s
  LCP:               1,832ms  (passes — it's the <p class="lead">, text)

This is not an LCP failure — the LCP element is the intro paragraph and it renders in 1.8s. It's a bandwidth and main-thread tax on a 36-product grid: 32 eager images compete with each other and with the JS for the same connection.

Fix: eager only the first row (forloop.index <= 4), lazy everything below the fold. The playbook's rule is one high-priority image per page; 32 is the same mistake as lazy-loading the hero, just inverted.

P4 · High

96 of 100 internal product links carry ?variant=

Evidence

Product hrefs on /collections/vibrators:  100
  with ?variant=…                        96
  clean /products/handle                  4

Example: /products/platinum-premium-silicone-the-lil-end?variant=49023048450231

This is the same class of error as the within: filter — internal links disagreeing with the canonical — just a milder version, because Shopify does canonicalise variant URLs to the base product. It's not a duplication emergency. But across a 1,000+ SKU catalogue it multiplies crawlable URLs and quietly spends the link signal you deliberately reclaimed when you removed within:.

Fix: drop the variant parameter from grid card links unless the card is genuinely a variant-specific entry point.

P5 · Medium

FAQPage schema on collection pages is now dead weight

/collections/vibrators carries a well-written six-question FAQPage block. Google dropped FAQ rich results entirely on 7 May 2026; Rich Results Test support goes in June 2026 and the Search Console API in August 2026.

The FAQ content is genuinely good and has real AI-extraction and on-page value — keep it. But the markup earns nothing in Google now and will start reporting as unsupported in validation tools.

Action: none required. Stop counting it as an SEO deliverable, and don't roll it out to more templates expecting rich results.

P6 · Medium

No ItemList on collection pages

Collection pages emit CollectionPage (name, description, url, isPartOf) and BreadcrumbList — but nothing enumerates the products. There is no dedicated category schema type in schema.org; ItemList with a ListItem per product is the workable substitute, and it's absent.

Fix: add mainEntity: ItemList to the existing CollectionPage node, with position + url per product on the current page.

P7 · High · Cheapest

Organization schema has no identity properties

Evidence — the entire OnlineStore node

{
  "@type": "OnlineStore",
  "@id": "https://coy.co.nz/#organization",
  "name": "Coy",
  "url": "https://coy.co.nz/",
  "description": "Sexual-wellness essentials, Coyscore-rated…",
  "hasMerchantReturnPolicy": { … }
}
missing: sameAs  logo  contactPoint  address

The playbook calls Organization schema with sameAs the machine-readable backbone of authoritativeness and brand-entity building. Coy publishes to six social channels every day via the UGC pipeline — those profile URLs are precisely what sameAs exists for, and not one of them is declared.

Fix: add sameAs (six channels), logo, contactPoint and NZ address to the existing node. Roughly fifteen lines, sitewide effect, zero risk.

P8 · Medium · Business decision

No AggregateRating anywhere — and nothing to legitimately earn one

Correctly, Coyscore is not being passed off as a review rating. The consequence is real though: zero review stars in the SERPs, on a catalogue where every competitor running Judge.me or Loox gets them.

The store surfaces "Reviews analysed: 48" from external sources — which cannot legitimately be marked up. Aggregating third-party reviews into aggregateRating is an explicit manual-action trigger, and the Rich Results Test won't catch it because it's a content violation, not a syntax one.

The only honest path to stars is first-party reviews displayed on the page. That's a business decision, not a code fix — but it's the single largest un-taken SERP click-through lever in the schema layer.

P9 · High · Content

E-E-A-T is thin at the page level

Evidence

sitemap_pages_1.xml  →  4 URLs total
   /pages/contact
   /pages/request-a-return
   /pages/data-sharing-opt-out
   /pages/sex-toys
no About · no Coyscore methodology · no shipping page

/policies/*   →  Disallowed by Shopify's default robots.txt
                 (the trust content that exists is uncrawlable)

Article author  →  { "@type":"Person", "name":"COY Editorial" }
                   no url · no sameAs · no bio · no credentials · no Article image

Sexual wellness sits close enough to YMYL that E-E-A-T carries more weight here than in general retail. A named editorial persona with a real bio page, plus a public Coyscore methodology page, is the highest-value content-side gap — and the methodology page doubles as the credibility asset the entire Coyscore proposition rests on.

P10 · Medium

Auto-generated vendor and type collections are crawlable and indexable

Evidence

/collections/vendors?q=CalExotics  → 200 · self-canonical · no noindex
/collections/types?q=Vibrators     → 200 · self-canonical · no noindex
/collections/all                   → 200 · self-canonical · not in sitemap

robots.txt custom rules:  ?*filter.   ✓
                          ?*sort_by=  ✓
                          ?*pb=       ✓
                          ?q=          ✗ not covered

These are thin near-duplicates of the real 109 collections. Exposure is low right now — they're not in the sitemap and no internal links to them were found — but they're a live surface waiting on a single external link.

Fix: add Disallow: /collections/vendors and Disallow: /collections/types to the existing Liquid block in robots.txt.liquid, preserving robots.default_groups.

P11 · Medium

Breadcrumbs can emit the junk frontpage collection

Evidence

wild-g-stimulator     Home > Toys > Home page > Rabbit Vibrators > Wild G Stimulator
                                    ↑ /collections/frontpage

colt-medium-pumper-plug              Home > Toys > Anal Toys > Inflatable Anal Toys > …   clean
basix-8-inch-suction-cup-dildo       Home > Toys > Dildos > Coloured & Phallic Dildos > … clean
nipple-play-bull-nose-nipple-jewelry Home > Toys > Bondage & Fetish > Nipple Play > …     clean

Three of four sampled products are clean, so this is a data-quality bug in the breadcrumb's collection-picker rather than a template failure — it takes whatever collection comes first and doesn't exclude the Shopify defaults.

Fix: exclude frontpage and automated-collection from breadcrumb eligibility. Blast radius unknown — worth a catalogue-wide count first.

P12 · Low

No ProductGroup for variant products

Variant products still emit a flat Product with a single Offer. No hasVariant, variesBy, productGroupID or isVariantOf.

Low priority: the catalogue looks predominantly single-variant, and only one variant product was sampled. There's also a known bug in Shopify's native ProductGroup output — all nested variants share one @id, so Google merges them — which is an argument for staying custom either way.

Fix: query the Coy source-of-truth DB to count multi-variant SKUs before deciding whether this is worth building at all.

Section 03

What I'm going to do about it

Ordered by return on effort, not by severity. The first three are small, contained theme edits that can ship together and be verified the same day.

  1. Product hero preload — tested, rejected, reverted

    Built and deployed to a preview theme, then measured: +8ms at 1.6 Mbps, +252ms at 5 Mbps across 3 of 3 runs. Reverted. The hero was bandwidth-bound, not discovery-bound, and at realistic bandwidth the page already passes at ~1.1s. Recorded in a code comment so it isn't re-derived. No further action without CrUX field data.

    Impact: noneDone — revertedCloses P1
  2. Gate the repeated collection blocks on page 1

    Wrap the intro, shop-by-type chips, Coyscore explainer, FAQ (visible + schema), buying guide and keep-exploring in {%- unless current_page == 1 -%} so pages 2+ render the grid only. Kills the duplication across 93 URLs without touching the canonical or cutting the collection-level link path to the 279 products that only appear on page 2+. Same pass: noindex on empty paginated grids to close the ?page=999 hole.

    Impact: highEffort: ~45 minFixes P2
  3. Fill in the Organization node

    Add sameAs for the six live social channels, plus logo, contactPoint and NZ address. Cheapest fix on the list, sitewide effect, zero regression risk, and it feeds directly into brand-entity building for AI search.

    Impact: highEffort: ~20 minFixes P7
  4. Close the two indexation loose ends

    Append Disallow: /collections/vendors and /collections/types to the existing Liquid robots block — extending robots.default_groups, never replacing the file. Then exclude frontpage and automated-collection from breadcrumb eligibility, after counting how many products are affected.

    Impact: mediumEffort: ~30 minFixes P10, P11
  5. Cap eager images on collection grids

    Eager the first row only, lazy the remaining 28+. Cuts 2.3 MB of first-load image transfer down to something proportionate and takes pressure off the main thread on mobile.

    Impact: mediumEffort: ~20 minFixes P3
  6. Strip ?variant= from grid card links

    Makes every internal link agree with its canonical, finishing the job the within: removal started. Needs a crawl afterwards to confirm the change landed everywhere, including any JS-driven filter rendering.

    Impact: mediumEffort: ~30 minFixes P4
  7. Add ItemList to collection schema

    Extend the existing CollectionPage node with mainEntity: ItemList, one ListItem per product on the current page. Validate every template type in the Rich Results Test afterwards.

    Impact: mediumEffort: ~45 minFixes P6
  8. Build the E-E-A-T pages

    A public Coyscore methodology page, an About page, and a named editorial author with a real bio and sameAs. This is content work rather than a theme edit, and it's the biggest long-term return on the list — the methodology page is also the credibility asset the whole Coyscore proposition depends on. Needs your input on who the named author is.

    Impact: highEffort: content sprintFixes P9
  9. Decide on first-party reviews

    The only legitimate route to review stars. Needs a decision from you on whether Coy collects and displays its own reviews — the Coyscore can't be marked up as a rating, and third-party aggregation is a manual-action risk. Not something I'll action without a call from you.

    Impact: highYour callAddresses P8
  10. Count multi-variant SKUs, then decide on ProductGroup

    One query against the source-of-truth DB. If variant density is low — which it looks to be — this stays unbuilt and that's the right answer.

    Impact: lowEffort: 1 queryFixes P12
  11. No action: FAQPage

    Leave the markup and the content where they are. The point is simply to stop treating it as an SEO deliverable and not extend it to further templates expecting rich results that no longer exist.

    Impact: n/aEffort: noneCloses P5

Section 04

What I couldn't verify

Stated plainly so nothing here reads as a clean bill of health it hasn't earned.

UnverifiedWhat it needs
GSC Pages report buckets
Duplicate without user-selected canonical, Excluded by noindex, Crawled – not indexed
Search Console property access. This is the fastest tell for canonical and pagination mistakes, and it's the one diagnostic I'm missing.
Which property the sitemap is submitted fromGSC access. If it's the myshopify property rather than the custom domain, that alone causes fetch failures and mixed signals.
301s for deleted or discontinued productsA delta from the source-of-truth DB plus a crawl of historical URLs. Shopify redirects nothing automatically on delete.
Leftover code from uninstalled appsTheme file access. The external script count is low, which is a good sign, but inline leftovers wouldn't show in that count.
Real-world Core Web VitalsThe origin has no CrUX data — traffic is below the sampling threshold. Everything in P1 is lab-measured under throttling, and real-world numbers will differ.

Section 05 · Shipped 21 Aug

Live on coy.co.nz

Five files, validated with Theme Check, previewed, then pushed to the live theme 153007653047 on 21 August. Verified on the public site afterwards.

Pushed surgically — the whole preview theme was NOT published. The local theme directory had drifted from live. Publishing wholesale would have deleted blocks/coy-subhead.liquid, reverted assets/coy.css (live carries mega-menu styles local doesn't), and rolled back the editor-managed templates/{collection,index,product}.json and sections/header-group.json. Only the five files below were pushed; a line-level diff confirmed zero live-only lines lost.

Rollback: pre-change originals are saved at projects/shopify-ai-store/theme-backups/2026-08-21-pre-seo-fixes/ with the exact restore command in its README.

FileChange
sections/coy-collection-header.liquidIntro, shop-by-type chips, Coyscore strip and the CollectionPage node gated to page 1. Added ItemList to CollectionPage (P6).
sections/coy-collection-footer.liquidFAQ, Coy promise, buying guide, keep-exploring and FAQPage JSON-LD gated to page 1. Coyscore disclaimer deliberately kept on every page — the badges render on every grid.
sections/coy-breadcrumb.liquidFallback now skips frontpage, automated-collection and all, so breadcrumbs stop emitting "Home page" (P11).
templates/robots.txt.liquidDisallow on /collections/vendors and /collections/types, still extending robots.default_groups (P10).
layout/theme.liquidnoindex, follow when a paginated page is past the last real page. Plus a comment recording the rejected preload experiment (P1).

Verified on the LIVE site after the push

                        page 1    page 2    page 999
  intro / chips / strip                      
  FAQ / guide / related                      
  Coyscore disclaimer                           (intentional)
  breadcrumb + H1                            
  product grid                               0 items
  CollectionPage + ItemList ✓ (32)             
  FAQPage schema                             
  canonical                self      self      self
  meta robots                                noindex, follow

  visible words         1,273       686       110      ← 46% less on page 2

Also confirmed live:
  /collections/dildos?page=3   blocks gone, grid intact, self-canonical
  product breadcrumb           Home > Toys > Vibrators > Rabbit Vibrators > Wild G Stimulator
                               (the junk "Home page" node is gone)
  robots.txt                   Disallow: /collections/vendors + /collections/types

Not shipped yet — needs your input:

P7 turned out not to be a code gap at all. snippets/coy-schema-foundation.liquid already emits sameAs, logo, contactPoint and legalName — correctly, omitting anything blank rather than inventing it. The settings exist under Theme settings → "COY — Business & schema". They're simply empty: only biz_return_days, biz_ship_rate and biz_free_ship_over are filled. I need the six social URLs, support email, legal name and logo from you — I won't invent them.

P9 needs a decision on who the named editorial author is. P8 needs your call on first-party reviews.

Not started: P3 (cap eager images on collection grids) and P4 (strip ?variant= from grid links). Both live in Horizon core snippets rather than Coy-custom files, so they carry more regression risk than the five above and I'd rather ship them as their own change with their own preview.

Section 06 · Sign-off & feedback

Sign off on what shipped

Five files are live and verified on the public site. Tick to confirm you're happy, or note anything you want changed or rolled back.

Sent straight through — one click, no email app needed. Every sign-off is logged.