SEO Audit & Implementation Guide
Shopify DTC — KNIC nicotine pouches (Dawn 15.2.0 theme). Eight parallel specialist analyses plus a second implementation-grade pass: paste-ready Liquid, schema, and compliance copy with exact file locations.
The technical build is solid — SSR, correct canonicals, passing Core Web Vitals in the field. What holds the score down: thin content, a 3-product architecture with no room to rank, and compliance gaps that are a legal problem before they're an SEO one.
Confirmed absent from the served HTML across every page (grepped, product and static pages):
✓ Age verification is in place — a client-side gate via the Good-Apps app (which is why a raw HTML fetch didn't see it). It just needs a dedupe (it loads twice) and a 21+/cookie-remember config check. Everything above still stands. Not legal advice — get counsel on the warning and PMTA status before driving more traffic in.
Nicotine is a restricted or prohibited category on Google Shopping & Merchant Center, Meta/Instagram ads, and Amazon. Paid acquisition is structurally closed — don't spend time trying to get Merchant Center approved. Growth has to come from organic search, email/SMS, affiliate (UpPromote is already installed), and direct/brand.
Against that, the architecture is the core problem: 3 products, one collection that isn't linked anywhere, and 84 variants collapsed onto a single URL. No page can rank for "wintergreen nicotine pouches" or "8mg nicotine pouches." Every ranking competitor runs per-flavor and per-strength indexable URLs. The comparison-shopper and subscription-buyer personas scored 23/100 — not because the pages are weak, but because the pages don't exist. Good news found in the deep dive: subscribe-and-save already exists (Casa Subscriptions, 10% monthly) — it just isn't surfaced.
Tap a workstream to expand. Each fix lists the exact Shopify/Dawn location, paste-ready code where relevant, and expected impact. All Liquid verified against the live Shopify/dawn repo.
FDA warning · disclaimer swap · NAP · age-gate dedupe
▸Exact required text, verbatim (cannot be paraphrased): "WARNING: This product contains nicotine. Nicotine is an addictive chemical." On advertisements — which §1143.3(b) explicitly extends to "Internet Web pages" — it must sit in the upper portion, occupy ≥20% of the ad area, be ≥12pt Arial/Helvetica bold, high-contrast, and be enclosed in a rectangular border.
snippets/nicotine-warning.liquid, rendered in layout/theme.liquid right after <body> so it's sitewide and can't be removed by section edits; plus a larger instance in sections/main-product.liquid under the buy-box.
<!-- snippets/nicotine-warning.liquid -->
<div class="nicotine-warning" role="note" aria-label="Nicotine warning">
<p>WARNING: THIS PRODUCT CONTAINS NICOTINE. NICOTINE IS AN ADDICTIVE CHEMICAL.</p>
</div>
<style>
.nicotine-warning{background:#000;color:#fff;border:4px solid #fff;
outline:3px solid #000;text-align:center;padding:.75rem 1rem;
font-family:Arial,Helvetica,sans-serif;font-weight:700;
font-size:max(12pt,1rem);letter-spacing:.02em}
</style>
Also flag to whoever runs paid social / label QA: the same warning must be burned into ad creative and appear on both principal display panels of packaging (≥30% of each panel) — neither is a theme fix.
The footer currently carries, on every page: "These statements and products…have not been evaluated by the Food and Drug Administration…not intended to diagnose, prevent, treat or cure…" — that's the dietary-supplement structure/function disclaimer (21 CFR 101.93). It's the wrong framework for a tobacco-product and signals regulatory illiteracy. Remove it from product/ingredient/GMP pages.
sections/footer.liquid (the rich-text/liquid block rendering that text).Replace with the mandatory §1143.3 warning line, plus recommended risk language (model on Zyn/On!/Rogue — counsel to sign off on final wording):
This product is intended for adult nicotine users 21 years of age or older. Not for sale to minors. Keep out of reach of children and pets. Not for use by women who are pregnant or breastfeeding, or by individuals with heart disease, high blood pressure, diabetes, or who are sensitive to nicotine. If accidentally ingested, contact Poison Control immediately at 1-800-222-1222.
Open item for legal: the site discloses nothing about PMTA status. Post-2022, FDA CTP authority extends to tobacco-free/synthetic nicotine; marketing without a marketing order is a live enforcement risk industry-wide. Needs a direct answer from regulatory/legal — not verifiable from the public site.
Put a real legal entity name, mailing address, support email and phone on /pages/contact (above the existing form) and a one-line NAP in the footer. Then wire the same values into Organization JSON-LD (see Workstream 3) so AI systems and Google can resolve "who makes this." Requires KNIC to supply real, verifiable values — don't ship placeholders.
The Good-Apps age gate loads twice: a legacy manual <script> in theme.liquid (fires on window.load) and the modern App Embed block. Delete the legacy snippet.
layout/theme.liquid → search good_apps_timer / goodAvAppsData / asyncLoad → delete that block. Keep the App Embed (Customize → App embeds). Verify one request in DevTools → Network.Config check: confirm it's a hard gate (blocks content, not a dismissible overlay), set to 21+ (federal Tobacco 21 minimum — not 18+), with real DOB entry and a cookie so returning visitors aren't re-gated every page.
Removes ~85KB duplicate JS transfer + parseFlavor/strength collections · nav · subscription landing
▸Splitting 84 variants into dozens of product pages would fragment the 29 pooled Judge.me reviews and read as doorway pages. Instead build tag-driven automated collections (zero new products): 2 strength, 10–14 flavor, 1 bundle, plus the existing /collections/all (currently orphaned — link it).
/collections/4mg-nicotine-pouches, /collections/8mg-nicotine-pouches/collections/spearmint-nicotine-pouches, …/wintergreen-…, …/grape-…, etc./collections/nicotine-pouch-bundlesflavor_spearmint, strength_4mg…); build each as an Automated collection with condition "Product tag equals …". Each collection canonicals to itself (unique curated copy). Product page canonical must stay the bare URL with no query string — Dawn already does this; don't let a dev make it per-variant.Replace the flat 5-item menu with a Shop mega-menu (By Strength / By Flavor / Bundles / Subscribe & Save). Add a breadcrumb trail Home > Shop > [Collection] > [Product] (schema in Workstream 3). Footer is currently 100% legal links — add a Shop column so discovery equity flows sitewide.
Subscribe-and-save already runs via Casa Subscriptions (10% monthly). Don't migrate apps. Build /pages/subscribe-and-save targeting "nicotine pouch subscription," and add the recurring price as a second Offer in the product JSON-LD so the discounted price is crawlable.
*sort_by*, *+*, *filter*&*filter*) — they auto-cover new collections. Cap Search & Discovery filters at 2 dimensions (Flavor, Strength); keep pack-size on the product page only.gtin12. Backfill the Barcode field on all 84 variants in Admin, then emit "gtin12": "{{ variant.barcode }}" in the product JSON-LD (stock Dawn doesn't map barcode→gtin). Multipacks need their own GS1 GTINs.Organization · Product + AggregateRating · Breadcrumb · ItemList
▸sameAsLive value is ["#","#","",…] — 9 placeholders matching Dawn's 9 social settings, hand-looped with bad fallbacks. Replace with a loop that only emits filled-in social settings, and fix @context to https.
sameAs (likely layout/theme.liquid or a custom snippet). Merchant must also fill the 9 social fields in Theme settings, or leave the array legitimately empty.{%- assign keys = 'social_facebook_link,social_instagram_link,social_youtube_link,social_tiktok_link,social_twitter_link,social_snapchat_link,social_pinterest_link,social_tumblr_link,social_vimeo_link' | split: ',' -%}
<script type="application/ld+json">
{
"@context":"https://schema.org","@type":"Organization",
"name":{{ shop.name | json }},"url":"{{ request.origin }}",
{%- if settings.logo != blank %}"logo":"https:{{ settings.logo | image_url: width: 500 }}",{% endif -%}
"sameAs":[
{%- assign n = 0 -%}
{%- for k in keys -%}{%- assign link = settings[k] -%}
{%- if link != blank -%}{%- if n > 0 %},{% endif %}{{ link | json }}{%- assign n = n | plus: 1 -%}{%- endif -%}
{%- endfor -%}]
}
</script>
Current product schema is Shopify's native {{ product | structured_data }} filter — closed, not extensible, so it can't carry ratings. Replace it. First check whether Judge.me's own schema toggle is on (it's currently emitting zero) — if you hand-roll and Judge.me turns on, you get duplicate AggregateRating (a validation error). Pick one source.
sections/main-product.liquid ~lines 749–751. Verify Judge.me's metafield namespace in Admin → Custom data → Products before relying on the metafield read.<script type="application/ld+json">
{
"@context":"https://schema.org",
"@id":"{{ request.origin }}{{ product.url }}#product",
"@type":"ProductGroup",
"name":{{ product.title | json }},
"url":"{{ request.origin }}{{ product.url }}",
"description":{{ product.description | strip_html | json }},
"productGroupID":"{{ product.id }}",
"brand":{"@type":"Brand","name":{{ product.vendor | json }}},
"aggregateRating":{
"@type":"AggregateRating",
"ratingValue":"{{ product.metafields.reviews.rating.value.rating | default: 4.93 }}",
"reviewCount":"{{ product.metafields.reviews.rating_count.value | default: 29 }}"
},
"hasVariant":[
{%- for v in product.variants -%}
{"@type":"Product",
"@id":"{{ request.origin }}{{ product.url }}?variant={{ v.id }}#variant",
"name":{{ v.title | prepend: product.title | prepend: ' – ' | json }},
{%- if v.sku != blank %}"sku":{{ v.sku | json }},{% endif -%}
"image":"https:{{ v.featured_image | default: product.featured_image | image_url: width: 1920 }}",
"offers":{"@type":"Offer",
"url":"{{ request.origin }}{{ product.url }}?variant={{ v.id }}",
"priceCurrency":"{{ cart.currency.iso_code }}",
"price":"{{ v.price | money_without_currency | strip_html }}",
"availability":"https://schema.org/{% if v.available %}InStock{% else %}OutOfStock{% endif %}"}
}{% unless forloop.last %},{% endunless %}
{%- endfor -%}]
}
</script>
Data-only fixes (Admin, not code): backfill SKU on the 44 "5 Pack" variants; fix variant image/name mismatches (a "Spearmint / 4mg / 5 Pack" variant points at the Grape photo — do a full 84-variant pass, this was a 3-variant spot check).
Dawn ships neither. Add a schema-only snippets/breadcrumbs.liquid (rendered at top of main-product.liquid and main-collection-product-grid.liquid) and an ItemList/CollectionPage block before the collection loop (~line 160). Keep ItemList minimal (ListItem + url) — each product's own schema lives on its page. FAQPage skipped — Google restricted it to gov/health in Aug 2023; no rich-result benefit for a commercial nicotine site.
Bootstrap removal · images · LCP · script deferral
▸Field CWV already pass at p75 (LCP 1.59s, INP 91ms, CLS 0), but the page is 6.2MB and fragile on slow devices. These protect the margin and fix a real product-page LCP delay.
bootstrap.min.css loads render-blocking on every page and is 97% unused (26,004 of 26,863 bytes). bootstrap.bundle.min.js loads unconditionally with zero data-bs-* usage anywhere — it does nothing. Grid classes appear only in 4 homepage Custom Liquid blocks; replace with ~20–30 lines of scoped CSS grid/flex, then delete both tags from theme.liquid.
The worst images are raw <img src> tags pasted into Custom Liquid with no width param, so the CDN serves full-res PNGs (flavors-bg.png 747KB, product cans 318–393KB) into ~400px boxes. Shopify's CDN already auto-serves AVIF/WebP via content-negotiation — you just need a width so it resizes. Run the images through image_url with srcset.
<img src="{{ 'flavors.png' | file_url | image_url: width: 750 }}"
srcset="{{ 'flavors.png' | file_url | image_url: width: 375 }} 375w,
{{ 'flavors.png' | file_url | image_url: width: 750 }} 750w,
{{ 'flavors.png' | file_url | image_url: width: 1100 }} 1100w"
sizes="(min-width:750px) 50vw, 100vw"
width="750" height="..." alt="KNIC pouch flavors" loading="lazy">
1,424 KB — biggest single byte-weight win
Homepage hero is already correct (fetchpriority="high" on slide 1). The product page active-slide <img> has no fetchpriority at all — the cause of the 511ms LCP "resource load delay" CrUX reports. Gate it on forloop.index == 1 in snippets/product-media.liquid, or minimally add fetchpriority="high" to the .slider__slide.is-active image.
The age-gate dedupe (Workstream 1) is also the single cleanest perf win (~85KB). Beyond that: FB Pixel / Klaviyo / Judge.me run through Shopify's Web Pixels Manager (already deferred ~10s — not yours to touch). GTM via the Stape proxy (bhs.knicpouches.com) is yours — wrap it in requestIdleCallback. Before touching anything Meta-related, confirm with the ad-account owner whether the separate meta-capi-param-builder is an intentional CAPI setup or an orphaned duplicate.
503 playbook · duplicate title · cart · headers
▸Bursty, not constant (couldn't reproduce on a later 100-request pass). The error body is Shopify's own error-pages template, not a Cloudflare-branded edge error — so it's the Shopify storefront-renderer origin erroring, not the proxy. This is Shopify-managed infra (x-dc: gcp-us-central1); there is almost certainly no separate Cloudflare dashboard to log into.
/, /robots.txt, /sitemap.xml to catch it in the act with a real failure rate.x-request-id + the server-timing requestID, then open a Shopify Support ticket with timestamps — only they have origin logs.Root cause found: Dawn's dedup guard uses case-sensitive contains, and homepage title "Knic Pouches" ≠ store name "KNIC Pouches", so the suffix double-appends → "Knic Pouches – KNIC Pouches".
{%- unless page_title | downcase contains shop.name | downcase %} in snippets/meta-tags.liquid.Bare /cart escapes the Disallow: /cart/ rule (prefix mismatch). In templates/robots.txt.liquid change to Disallow: /cart (drops the slash → covers both), and add a conditional <meta name="robots" content="noindex,follow"> when template contains 'cart'. HSTS/CSP hardening is Shopify-platform controlled — document as a known ceiling, not an action item. www→non-www 301 confirmed clean (8/8).
Blog plan · About rewrite · trust signals
▸Dead since Sept 2024, zero posts. One of the few organic levers open to this category. Structure every post answer-first: a 40–60 word direct answer in paragraph one, 4–6 question-H2s, an FAQ block, inline citations to FDA/peer-reviewed sources.
Currently 90 words of templated copy, zero named people, zero location. Add named founder(s) with bios/photos, manufacturing location, a real founding narrative, concrete facility/cert specifics (the GMP page's "100+ years combined experience" is unattributed), and cross-links to Ingredients/GMP/Contact. This is the one place more words alone won't help — it needs real, verifiable people and facts.
Copy says "10 flavors" but 14 are listed; homepage flavor card misspells "Cinammon" and reuses one beach description for both cinnamon and raspberry-lemon; ingredients table has typos ("Distilld," "Propylparabe," "Bicardonate"). Small, but they undercut expertise on an ingestible product.
AI-crawler robots.txt · llms.txt · authority · passage rewrites
▸Genuinely ahead of the curve on agentic commerce (verified /agents.md, /.well-known/ucp, live UCP/MCP endpoint with wired payment handlers — set up to be bought from by ChatGPT/Shop agents, not just cited). The ceiling is authority: an independent search found zero third-party mentions of "KNIC Pouches."
The store already has a custom templates/robots.txt.liquid. Add explicit groups: allow GPTBot, OAI-SearchBot, ClaudeBot, PerplexityBot, Google-Extended (visibility); block CCBot, anthropic-ai, cohere-ai (training-only). Note GPTBot is dual-purpose — allowing citation also allows training scrape.
User-agent: GPTBot
Allow: /
User-agent: OAI-SearchBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: PerplexityBot
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: CCBot
Disallow: /
User-agent: anthropic-ai
Disallow: /
Current /llms.txt just mirrors agents.md. Replace with a spec-shaped index (H1 + summary blockquote + grouped links to products, ingredients, GMP, UCP endpoints). It's served by a Cloudflare Worker route, not Shopify — swap that route's body. Leave the (empty) blog out until posts exist. Note: sitemap_agentic_discovery.xml is Shopify-generated and can't be hand-edited — reference llms.txt from agents.md and robots.txt instead.
Front-load direct facts. Example for the ingredients page:
What's in a KNIC Nicotine Pouch?
KNIC Nicotine Pouches contain five ingredients: tobacco-free nicotine (salt-based, from pure leaf) in 4mg or 8mg strength, a plant-based stabilizer (under 2%), natural flavor with stevia (under 2%), a sodium bicarbonate pH balancer (under 1%), and a microcrystalline filler, sealed in an all-natural cotton pouch.56 words · question-H2 + self-contained answer = the shape AI Overviews lift verbatim
Standard playbook mostly doesn't apply (Reddit/YouTube/Meta ban nicotine ads). Open lane = organic disclosed content: (1) Trustpilot + on-site reviews first — closes the schema gap too; (2) seed sample packs to independent pouch reviewers on r/ZYN, r/nicotine, YouTube unboxers (not as the brand account); (3) owned YouTube education explainers (informational, not ad-format). Deprioritize Wikipedia until real press exists.
Week 1 — legal + no-code: counsel review of warning/PMTA; add the FDA warning snippet; swap the disclaimer; add NAP; dedupe the age gate; fix homepage title/H1/meta (all Admin-editable).
Weeks 2–3 — theme PR: one Dawn PR bundling Bootstrap removal + image pipeline + product-hero fetchpriority + the corrected schema (Organization, Product+AggregateRating, Breadcrumb, ItemList). Backfill SKUs/variant images in Admin.
Ongoing — organic engine: build the flavor/strength collections + subscription landing page + nav; ship the blog cadence; start the off-site review/mention build. This is the only channel open, so it's where the real growth is.
Regulatory notes are informational, not legal advice — confirm warning placement, disclaimer wording, and PMTA status with qualified counsel before publishing. All Liquid should be tested on a duplicate/unpublished theme before going live.