<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Cody Wang</title>
    <description>The latest articles on DEV Community by Cody Wang (@amazong110).</description>
    <link>https://dev.to/amazong110</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4139050%2F9d60ef5c-4ccd-4154-876e-9f056d978f9a.jpg</url>
      <title>DEV Community: Cody Wang</title>
      <link>https://dev.to/amazong110</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/amazong110"/>
    <language>en</language>
    <item>
      <title>What is this site actually built with? Three checks and 13 signals</title>
      <dc:creator>Cody Wang</dc:creator>
      <pubDate>Tue, 13 Oct 2026 06:09:05 +0000</pubDate>
      <link>https://dev.to/amazong110/what-is-this-site-actually-built-with-three-checks-and-13-signals-h6g</link>
      <guid>https://dev.to/amazong110/what-is-this-site-actually-built-with-three-checks-and-13-signals-h6g</guid>
      <description>&lt;h1&gt;
  
  
  What is this site actually built with? Three checks and 13 signals
&lt;/h1&gt;

&lt;p&gt;When you inherit a site — or want to change your own — the first thing you need is not the code. It's &lt;strong&gt;what the thing is built with&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Because the stack decides what is possible afterwards.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Whether you can change it at all.&lt;/strong&gt; A WordPress site's heading font might live in one theme setting. On Webflow you may not even get the HTML. A headless CMS means editing a content model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether the images can be saved.&lt;/strong&gt; WordPress serves from &lt;code&gt;/wp-content/uploads/&lt;/code&gt;, Shopify from &lt;code&gt;cdn.shopify.com&lt;/code&gt;, Prismic from &lt;code&gt;images.prismic.io&lt;/code&gt; — and each pipeline takes a different set of transform parameters. Miss the pipeline and you don't know what to tune.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache and render strategy.&lt;/strong&gt; Headless leans on CDN caching for API JSON and an HTML shell; classic PHP leans on full-page and object caches; hosted builders give you the fewest knobs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether you can touch the code.&lt;/strong&gt; Some platforms give you a panel. Some give you everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In one line: &lt;strong&gt;identify the platform first, then optimise.&lt;/strong&gt; The other order wastes your time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three checks
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Check 1: View source, then search it
&lt;/h3&gt;

&lt;p&gt;Open the page → &lt;code&gt;Ctrl+U&lt;/code&gt; (&lt;code&gt;Cmd+Option+U&lt;/code&gt; on Mac) → search the HTML for the vendor name you suspect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The distinction that matters:&lt;/strong&gt; if the name only shows up in body copy or a comment, it proves nothing. What counts is the name appearing inside a &lt;code&gt;src&lt;/code&gt;, &lt;code&gt;href&lt;/code&gt; or &lt;code&gt;data-&lt;/code&gt; attribute — that is a resource the browser was &lt;strong&gt;told to load&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A concrete example: you search for &lt;code&gt;prismic&lt;/code&gt; and find it inside a &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; tag. That site may just have published an article about Prismic. But if it appears in &lt;code&gt;src="https://images.prismic.io/..."&lt;/code&gt;, that is hard evidence.&lt;/p&gt;

&lt;p&gt;So the question is never "is this word here" — it is &lt;strong&gt;where the word appears&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check 2: Read the response headers, especially the cookies
&lt;/h3&gt;

&lt;p&gt;Open dev tools → Network → reload → click the first document request. Look at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;x-powered-by&lt;/code&gt;, &lt;code&gt;x-generator&lt;/code&gt;, &lt;code&gt;server&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the names inside &lt;code&gt;set-cookie&lt;/code&gt;&lt;/strong&gt; — the most valuable of the lot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why are cookies stronger than meta tags? Because &lt;strong&gt;meta tags are written by a human, cookies are issued by the platform&lt;/strong&gt;. A cookie called &lt;code&gt;wp-settings-1&lt;/code&gt; or &lt;code&gt;_shopify_s&lt;/code&gt; is the platform introducing itself.&lt;/p&gt;

&lt;p&gt;⚠️ One trap: &lt;strong&gt;CDNs strip headers.&lt;/strong&gt; Put Cloudflare or Vercel in front and a lot of the original headers are simply gone. So "I don't see it" is &lt;strong&gt;not&lt;/strong&gt; the same as "it isn't there". It's one vote, not a verdict.&lt;/p&gt;

&lt;h3&gt;
  
  
  Check 3: Compare against a site you already know
&lt;/h3&gt;

&lt;p&gt;The same platform leaves &lt;strong&gt;the same shape&lt;/strong&gt; on every site that runs it: the same asset host, the same path prefixes, the same admin endpoints.&lt;/p&gt;

&lt;p&gt;That is the whole reason CMS detection works at all — not guessing, but shape matching.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A single keyword → maybe a false positive&lt;/li&gt;
&lt;li&gt;Keyword + matching host + matching path → that is confirmation&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  13 signals worth memorising
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WordPress&lt;/strong&gt;: &lt;code&gt;/wp-content/&lt;/code&gt;, &lt;code&gt;/wp-includes/&lt;/code&gt;, &lt;code&gt;/wp-json/&lt;/code&gt;, the &lt;code&gt;wp-emoji-release&lt;/code&gt; script, &lt;code&gt;wp-*&lt;/code&gt; cookies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shopify&lt;/strong&gt;: &lt;code&gt;cdn.shopify.com&lt;/code&gt;, &lt;code&gt;cdn.shopifycdn.com&lt;/code&gt;, &lt;code&gt;/cdn/shop/&lt;/code&gt;, &lt;code&gt;Shopify.theme&lt;/code&gt; in page scripts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wix&lt;/strong&gt;: &lt;code&gt;static.wixstatic.com&lt;/code&gt;, &lt;code&gt;static.parastorage.com&lt;/code&gt;, the inline viewer model JSON&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Squarespace&lt;/strong&gt;: &lt;code&gt;static1.squarespace.com&lt;/code&gt;, &lt;code&gt;Static.SQUARESPACE_CONTEXT&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webflow&lt;/strong&gt;: &lt;code&gt;website-files.com&lt;/code&gt;, &lt;code&gt;website-images-assets.com&lt;/code&gt;, &lt;code&gt;data-wf-*&lt;/code&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prismic&lt;/strong&gt;: &lt;code&gt;images.prismic.io&lt;/code&gt;, &lt;code&gt;/prismic-assets/&lt;/code&gt;, slice markers, &lt;code&gt;prismic-toolbar&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contentful&lt;/strong&gt;: &lt;code&gt;images.ctfassets.net&lt;/code&gt;, &lt;code&gt;assets.ctfassets.net&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sanity&lt;/strong&gt;: &lt;code&gt;cdn.sanity.io&lt;/code&gt;, the project id referenced in page scripts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ghost&lt;/strong&gt;: &lt;code&gt;/ghost/&lt;/code&gt;, &lt;code&gt;/content/&lt;/code&gt; paths, &lt;code&gt;ghost-*&lt;/code&gt; meta tags, the Ghost admin SDK&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drupal&lt;/strong&gt;: &lt;code&gt;/sites/default/files/&lt;/code&gt;, &lt;code&gt;drupalSettings&lt;/code&gt;, the &lt;code&gt;X-Generator&lt;/code&gt; response header&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Joomla&lt;/strong&gt;: &lt;code&gt;/media/jui/&lt;/code&gt;, &lt;code&gt;/media/system/&lt;/code&gt;, &lt;code&gt;/components/com_&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Craft CMS&lt;/strong&gt;: the &lt;code&gt;CRAFT_CSRF_TOKEN&lt;/code&gt; cookie, &lt;code&gt;/cpresources/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strapi&lt;/strong&gt;: a &lt;code&gt;/uploads/&lt;/code&gt; path together with an &lt;code&gt;api.&lt;/code&gt; host or &lt;code&gt;/api/&lt;/code&gt; JSON endpoints&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  A real example
&lt;/h2&gt;

&lt;p&gt;Let's practise on a site everyone knows: the TechCrunch homepage.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk1qkjomsrdvb46w0z8kr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk1qkjomsrdvb46w0z8kr.png" alt="TechCrunch homepage source, with the WordPress-only paths visible" width="800" height="225"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the source&lt;/strong&gt;, these two lines are decisive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"https://api.w.org/"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://techcrunch.com/wp-json/"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://techcrunch.com/wp-includes/js/dist/i18n.min.js?ver=..."&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"wp-i18n-js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/wp-json/&lt;/code&gt; and &lt;code&gt;/wp-includes/&lt;/code&gt; are WordPress-only paths, and they sit inside &lt;code&gt;href&lt;/code&gt; and &lt;code&gt;src&lt;/code&gt; — the browser genuinely loads them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the response headers&lt;/strong&gt; it is even more direct:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;x-powered-by: WordPress VIP &amp;lt;https://wpvip.com&amp;gt;
link: &amp;lt;https://techcrunch.com/wp-json/&amp;gt;; rel="https://api.w.org/"
server: cloudflare
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;x-powered-by&lt;/code&gt; names the platform outright, and the &lt;code&gt;link&lt;/code&gt; header hands you the REST API address.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftjyymonkjd8htkyl5p2j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftjyymonkjd8htkyl5p2j.png" alt="The same page's response headers, with x-powered-by naming WordPress VIP" width="800" height="318"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Note that last line: &lt;code&gt;server&lt;/code&gt; is &lt;strong&gt;cloudflare&lt;/strong&gt;, not the origin. That is the "CDNs strip headers" problem in the wild — with a CDN in front, you see one layer less.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0yikyhnh1x3mmymdkjgs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0yikyhnh1x3mmymdkjgs.png" alt="The audit tool listing every hit with its evidence" width="800" height="906"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The two most common mistakes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Mistake one: finding the brand name in body copy and calling it done.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Documentation sites, comparison posts and job ads list several vendors at once. None of that is proof of an install.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mistake two: concluding from a single signal.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Plenty of sites sit behind Cloudflare. Cloudflare is a CDN — it says nothing about which CMS runs underneath.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The right approach&lt;/strong&gt; is to read the signals in layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vendor-specific hosts (strong) &amp;gt; path prefixes (strong) &amp;gt; cookies (strong)&lt;/li&gt;
&lt;li&gt;Response headers (medium — CDNs strip them)&lt;/li&gt;
&lt;li&gt;Brand strings in page copy (weak)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  If you'd rather not check by hand
&lt;/h2&gt;

&lt;p&gt;Paste the URL into &lt;strong&gt;&lt;a href="https://prismicaudit.com/tools/cms-checker?from=devto" rel="noopener noreferrer"&gt;prismicaudit.com/tools/cms-checker&lt;/a&gt;&lt;/strong&gt;. It runs 100+ detectors across CMS, frameworks, hosting, CDN and third-party scripts, and &lt;strong&gt;lists the raw evidence behind every hit&lt;/strong&gt; — not just a yes/no.&lt;/p&gt;

&lt;p&gt;I care about giving evidence rather than verdicts, because &lt;strong&gt;you should be able to check the answer yourself&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The checks described here work on any site. Vendor names are used only to describe identification fingerprints; this tool is not affiliated with any of them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>cms</category>
      <category>seo</category>
      <category>tooling</category>
    </item>
    <item>
      <title>AI can generate a Shopify section that looks fine, not one you can edit</title>
      <dc:creator>Cody Wang</dc:creator>
      <pubDate>Sun, 04 Oct 2026 09:01:03 +0000</pubDate>
      <link>https://dev.to/amazong110/ai-can-generate-a-shopify-section-that-looks-fine-not-one-you-can-edit-4fde</link>
      <guid>https://dev.to/amazong110/ai-can-generate-a-shopify-section-that-looks-fine-not-one-you-can-edit-4fde</guid>
      <description>&lt;p&gt;A theme I was reviewing had a section that would not move. You could pick it up in the theme editor, drag it, and it would return to its original position. Nothing in the console, no error in the editor. The section rendered correctly on the storefront.&lt;/p&gt;

&lt;p&gt;The cause was one attribute. The section had &lt;code&gt;id="hero-1"&lt;/code&gt; in its markup instead of &lt;code&gt;{{ section.id }}&lt;/code&gt;. Shopify uses that value to tell one section instance from another, so two instances of the same section had the same ID, and the editor could not keep track of which one you were dragging. Replacing the hardcoded value with &lt;code&gt;{{ section.id }}&lt;/code&gt; fixed it.&lt;/p&gt;

&lt;p&gt;That theme came from an AI tool. The four issues below are the ones I keep finding in themes built this way. Two of them show up as bugs in the editor, two of them show up months later.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Hardcoded IDs in the section markup
&lt;/h2&gt;

&lt;p&gt;Liquid sections need &lt;code&gt;{{ section.id }}&lt;/code&gt; for their identity. A hardcoded value works while there is only one instance of the section on the page. Add a second one, or duplicate the section into a template twice, and both render with the same ID. The storefront still looks right. The editor stops behaving predictably.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Missing presets
&lt;/h2&gt;

&lt;p&gt;Sections that merchants should be able to add need a &lt;code&gt;presets&lt;/code&gt; array in the schema. Without it, the section renders wherever it is already placed in a template, so it passes a visual check. It never appears in the Add section menu.&lt;/p&gt;

&lt;p&gt;The same applies to &lt;code&gt;{{ block.shopify_attributes }}&lt;/code&gt;. Without it on a block, the block renders but cannot be selected in the editor, so merchants cannot change its settings or reorder it.&lt;/p&gt;

&lt;p&gt;Both of these only appear when someone tries to edit the theme. That is why they survive a review, a screenshot check, and a demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. CSS inside the section markup
&lt;/h2&gt;

&lt;p&gt;Many generated sections include a &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; block in the section itself. This works, and it has two costs. The rules reload on every page where the section appears, and when two sections define the same selector, the result depends on load order rather than on which file owns the style. Shopify's &lt;code&gt;{% stylesheet %}&lt;/code&gt; tag handles this: one request, deduplicated, scoped to the section.&lt;/p&gt;

&lt;p&gt;The same pattern shows up in responsive rules. Generated sections often contain the desktop layout and skip the breakpoint at 750px, so the section tests clean at 1440px and breaks on a phone. For a store where most sessions are mobile, that is the layout that matters most.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Code that disappears on theme update
&lt;/h2&gt;

&lt;p&gt;When a theme update is published, Shopify keeps the merchant's settings and template edits and leaves custom code alone. Code that sits outside the theme's own structure can be missing from the new version. This is the expensive one, because the merchant ends up with a feature that has no readable source and no one who remembers how it was built.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Shopify says about this
&lt;/h2&gt;

&lt;p&gt;Shopify's theme support page lists AI-generated theme blocks among the customizations its support team will not troubleshoot. That means the merchant has no support path if the person who wrote the code is unavailable.&lt;/p&gt;

&lt;p&gt;The requirements for listing a theme in the Shopify Theme Store set a higher bar than "it renders":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Average Lighthouse performance of at least 60 and accessibility of at least 90, measured on the home, product, and collection pages, on desktop and mobile, with real content in the sections.&lt;/li&gt;
&lt;li&gt;Original code. Themes derived from Dawn or Horizon are not eligible as new submissions.&lt;/li&gt;
&lt;li&gt;No fake urgency components such as countdown timers or low-stock counters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those requirements apply to the official store. Themes sold elsewhere are not reviewed against them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A five-minute check for any theme
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open the theme editor on a page that has your sections and try to move one to a different position.&lt;/li&gt;
&lt;li&gt;Check whether the section appears in the Add section menu.&lt;/li&gt;
&lt;li&gt;Open the store on a phone, add a product to the cart, and complete the flow to the cart page.&lt;/li&gt;
&lt;li&gt;Press Tab through the page and confirm you can see where focus is.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the section does not move, does not appear in the menu, breaks on mobile, or the focus indicator is missing, the theme needs work before a merchant can rely on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I build sections
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://prismicaudit.com/pet-field-guide" rel="noopener noreferrer"&gt;a theme for pet stores&lt;/a&gt; with 44 sections and 20 page templates. Each section uses &lt;code&gt;{{ section.id }}&lt;/code&gt;, ships with presets, includes &lt;code&gt;{{ block.shopify_attributes }}&lt;/code&gt; on its blocks, and keeps its CSS in a stylesheet tag.&lt;/p&gt;

&lt;p&gt;Before release I loaded the home, collection, and product pages at six widths from 375px to 1440px and checked for horizontal overflow, broken images, dead links, and keyboard traps. No horizontal overflow at any width. Touch targets measured at 24x24 or larger to meet the WCAG 2.5.8 minimum, which required resizing six link styles that were initially smaller.&lt;/p&gt;

&lt;p&gt;None of that is visible in a screenshot. It shows up when the merchant changes the store themselves.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>webdev</category>
      <category>ecommerce</category>
      <category>a11y</category>
    </item>
    <item>
      <title>hreflang said English, canonical said Chinese. Google believed the canonical.</title>
      <dc:creator>Cody Wang</dc:creator>
      <pubDate>Wed, 30 Sep 2026 02:55:00 +0000</pubDate>
      <link>https://dev.to/amazong110/hreflang-said-english-canonical-said-chinese-google-believed-the-canonical-10d3</link>
      <guid>https://dev.to/amazong110/hreflang-said-english-canonical-said-chinese-google-believed-the-canonical-10d3</guid>
      <description>&lt;p&gt;I'm Cody Wang. I build and optimise Prismic, SvelteKit and Next.js sites, mostly for clients in New Zealand and for Chinese brands going international.&lt;/p&gt;

&lt;p&gt;Last week I put a small SEO audit tool online. Most of the week went into the unglamorous half of launching: making sure search engines could actually read what I'd built. I found one bug that had quietly removed half the site from the index, and it took me three days to spot because every single page looked fine on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the site is
&lt;/h2&gt;

&lt;p&gt;It's a detector. You paste a URL, it tells you what the site is built with and lists the fixable problems.&lt;/p&gt;

&lt;p&gt;Two languages, one URL, language in a query parameter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/tools/cloudflare          Chinese (default)
/tools/cloudflare?lang=en  English
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;66 URLs, all server rendered. The content is in the HTML, no JavaScript required.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I was telling Google
&lt;/h2&gt;

&lt;p&gt;Here's the head of the English page for the Cloudflare detector:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Cloudflare Detector — check if a website uses Cloudflare&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"canonical"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://prismicaudit.com/tools/cloudflare"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"zh-Hans"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://prismicaudit.com/tools/cloudflare?lang=zh"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://prismicaudit.com/tools/cloudflare?lang=en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"x-default"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://prismicaudit.com/tools/cloudflare"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the canonical. The English page is declaring that its real URL is the Chinese one.&lt;/p&gt;

&lt;p&gt;So I was saying two contradictory things on the same page. hreflang says the English version lives at &lt;code&gt;?lang=en&lt;/code&gt;. The canonical says the canonical version lives at the bare URL, which serves Chinese. When those two disagree, Google follows the canonical. The English page becomes a duplicate of the Chinese page, and 1,600 words of English copy I wrote for English searchers gets folded into a page in a language those searchers can't read.&lt;/p&gt;

&lt;p&gt;The cause was dull. My head builder took &lt;code&gt;lang&lt;/code&gt; as its first argument and used it for the &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; attribute, the title, the description, the structured data, the font stack. Then it built the canonical out of the path alone, because that line was written before the language switching existed and nobody updated it when the language switching arrived. One argument that got passed in and never used, on every page of the site.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I noticed
&lt;/h2&gt;

&lt;p&gt;Not from a dashboard alert. There isn't one for this.&lt;/p&gt;

&lt;p&gt;Search Console showed 199 impressions and 8 clicks across four weeks. Four of the five pages that earned a click were &lt;code&gt;?lang=en&lt;/code&gt; URLs. The Chinese pages, which were the canonical targets, were picking up scraps.&lt;/p&gt;

&lt;p&gt;That's the tell. Almost everything getting impressions was something I'd told Google to ignore.&lt;/p&gt;

&lt;p&gt;You can check this on your own site faster with curl than with any tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://prismicaudit.com/tools/cloudflare?lang=en"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;link rel="canonical"[^&amp;gt;]*&amp;gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the canonical that comes back has no &lt;code&gt;lang&lt;/code&gt; in it while the page itself is in English, you have the same problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Each language version gets a canonical pointing at itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"canonical"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://prismicaudit.com/tools/cloudflare"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;           &lt;span class="c"&gt;&amp;lt;!-- zh --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"canonical"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://prismicaudit.com/tools/cloudflare?lang=en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;   &lt;span class="c"&gt;&amp;lt;!-- en --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hreflang block stays, except &lt;code&gt;x-default&lt;/code&gt; now points at the English version. That one is a judgement call and not a rule. &lt;code&gt;x-default&lt;/code&gt; is the page you show when no language matches, so it should be the version your audience actually searches in. Mine is English, so that's where it points. On a site aimed at the Chinese market I'd point it the other way.&lt;/p&gt;

&lt;p&gt;The sitemap needed the same treatment. It was emitting one &lt;code&gt;&amp;lt;loc&amp;gt;&lt;/code&gt; per path with three alternates, which never put the English URL in the sitemap at all. Now each language gets its own entry, so 66 URLs became 132.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying it
&lt;/h2&gt;

&lt;p&gt;I don't trust "looks right." I wrote a loop that walks every URL in the sitemap and checks the canonical on the English version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;U&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://prismicaudit.com"&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$U&lt;/span&gt;&lt;span class="s2"&gt;/sitemap.xml"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;loc&amp;gt;[^&amp;lt;]*&amp;lt;/loc&amp;gt;'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'s/&amp;lt;loc&amp;gt;//'&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'s/&amp;lt;\/loc&amp;gt;//'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'lang=en'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s2"&gt;"s#^&lt;/span&gt;&lt;span class="nv"&gt;$U&lt;/span&gt;&lt;span class="s2"&gt;##"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/paths.txt

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; p&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;got&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$U$p&lt;/span&gt;&lt;span class="s2"&gt;?lang=en"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'rel="canonical" href="[^"]*"'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$got&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"rel=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;canonical&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; href=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$U$p&lt;/span&gt;&lt;span class="s2"&gt;?lang=en&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FAIL &lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$got&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &amp;lt; /tmp/paths.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;66 paths, 66 passes, no output. Mobile Lighthouse on a detector page still reads 100 for performance, accessibility, best practices and SEO. The fix didn't cost anything measurable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I got wrong while fixing it
&lt;/h2&gt;

&lt;p&gt;The homepage had its own head builder, separate from the page template, so the same bug needed fixing twice. I patched the homepage by running a regex over the already-rendered HTML instead of fixing the template that produced it.&lt;/p&gt;

&lt;p&gt;It passes every check I have. It's also fragile in a way I'll pay for later: if the order of those three link tags ever changes, the regex matches nothing, silently, and the bug comes back with no error anywhere. I left a note in the file. Next time I touch that template it goes in the template.&lt;/p&gt;

&lt;p&gt;Two head builders and one regex. The actual lesson wasn't about hreflang.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still don't know
&lt;/h2&gt;

&lt;p&gt;Whether it worked.&lt;/p&gt;

&lt;p&gt;Canonical and hreflang are hints for crawlers. I've changed the hints. Now Google has to come back, re-crawl 132 URLs, and re-evaluate which page is the real one. That takes days to weeks, and until it does, both versions are in a weird state where the signals contradict what Google last saw.&lt;/p&gt;

&lt;p&gt;So I'm in the waiting part. Nothing to design, nothing to ship, just checking Search Console every few days to see whether the English URLs start collecting impressions the way they should have been all along.&lt;/p&gt;

&lt;p&gt;If you run a multilingual site, the curl loop takes ten minutes. Nothing in a Lighthouse report will ever show you this one.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>html</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Six things that keep a site off a perfect Lighthouse score</title>
      <dc:creator>Cody Wang</dc:creator>
      <pubDate>Wed, 23 Sep 2026 08:45:20 +0000</pubDate>
      <link>https://dev.to/amazong110/six-things-that-keep-a-site-off-a-perfect-lighthouse-score-3ecg</link>
      <guid>https://dev.to/amazong110/six-things-that-keep-a-site-off-a-perfect-lighthouse-score-3ecg</guid>
      <description>&lt;p&gt;I'm Cody Wang. I build and optimise Prismic, SvelteKit and Next.js sites, mostly for clients in New Zealand and for Chinese brands going international.&lt;/p&gt;

&lt;p&gt;These six things are ones I've fixed myself, more than once. No theory dump. Just what it looked like, what I assumed at first, what I actually changed, and where the official docs are.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Images: alt text, dimensions, file size
&lt;/h2&gt;

&lt;p&gt;The most common one is a missing &lt;code&gt;alt&lt;/code&gt;. People either write &lt;code&gt;alt="image"&lt;/code&gt; or stuff keywords in there. Both read badly through a screen reader. Alt text describes the picture to someone who can't see it: &lt;code&gt;alt="Dark grey steel garage door after installation, seen from the street"&lt;/code&gt;. Purely decorative images get &lt;code&gt;alt=""&lt;/code&gt; — an empty string, not an omitted attribute.&lt;/p&gt;

&lt;p&gt;If editors upload content through a CMS, the template has to cover for them: &lt;code&gt;alt={image.alt || contextFallback}&lt;/code&gt;. Don't assume everyone remembers. On one client site I counted 32 images without alt; after the fix, zero.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://web.dev/learn/accessibility/images" rel="noopener noreferrer"&gt;https://web.dev/learn/accessibility/images&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Second issue is dimensions. Without &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt;, the browser doesn't know how much room an image needs. It lays out everything else first, then reflows once the image arrives, and the user watches the content jump. That's where CLS comes from.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/hero.webp"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"630"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"…"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs: &lt;a href="https://web.dev/articles/optimize-cls" rel="noopener noreferrer"&gt;https://web.dev/articles/optimize-cls&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Third is file size, and it's the most visible one. A hero exported straight from the design file can land at 527 KB. I once found a star rating icon, an SVG, weighing 123 KB when it should have been about 2 KB.&lt;/p&gt;

&lt;p&gt;I do three things together: convert to WebP or AVIF, serve the size the layout actually uses (a 400px card doesn't need a 2000px image), and let the CDN handle it with something like &lt;code&gt;?auto=format,compress&amp;amp;q=75&amp;amp;w=800&lt;/code&gt;. Also don't lazy-load the hero — give it &lt;code&gt;fetchpriority="high"&lt;/code&gt; and lazy-load the rest.&lt;/p&gt;

&lt;p&gt;Static assets on one site went from 996 KB to 79 KB.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://developer.chrome.com/docs/lighthouse/performance/uses-webp-images" rel="noopener noreferrer"&gt;https://developer.chrome.com/docs/lighthouse/performance/uses-webp-images&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Don't load the whole font family
&lt;/h2&gt;

&lt;p&gt;Text goes blank for a second on mobile, and LCP suffers for it.&lt;/p&gt;

&lt;p&gt;Usually the entire family is being pulled down at once: four to six weights, plus full character sets. While it downloads, the browser may show nothing at all. Chinese sites hit this hard, since a single CJK font can run into megabytes.&lt;/p&gt;

&lt;p&gt;What I do: keep only the weights in use, say 400 and 600; ship woff2 split by &lt;code&gt;unicode-range&lt;/code&gt;, so an English page never fetches the CJK file; and set &lt;code&gt;font-display: swap&lt;/code&gt; so text appears in the system font first.&lt;/p&gt;

&lt;p&gt;On one site that was Roboto at 468 KB plus Raleway at 309 KB, 777 KB total, and after the split 57 KB plus 43.7 KB — 100.7 KB.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://developer.chrome.com/docs/lighthouse/performance/font-display" rel="noopener noreferrer"&gt;https://developer.chrome.com/docs/lighthouse/performance/font-display&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Third-party scripts: not whether, but when
&lt;/h2&gt;

&lt;p&gt;Scrolling feels sticky, clicking a button does nothing for a moment. That's usually total blocking time.&lt;/p&gt;

&lt;p&gt;GTM, the Facebook pixel, chat widgets — they all load as synchronous JavaScript and compete with your page for the main thread. While it's busy, taps do nothing.&lt;/p&gt;

&lt;p&gt;Deleting them is cleanest, but on most client work the tracking scripts are a business requirement and I can't touch them. So I change when they load instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;scroll&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;keydown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mousemove&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;touchstart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loadTags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;once&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;passive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="nx"&gt;requestIdleCallback&lt;/span&gt;&lt;span class="p"&gt;?.(&lt;/span&gt;&lt;span class="nx"&gt;loadTags&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loadTags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inject them once the page is idle, or after the first sign of interaction. Nothing is removed and nothing stops reporting. With that change on one site, TBT went from 284 ms to 179 ms.&lt;/p&gt;

&lt;p&gt;Worth knowing before you promise a number: changing when a script loads is something you can usually get approved. Removing it is not. Where the tracking scripts stay, mobile performance has a ceiling, and it's not a ceiling you can code your way past.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://developer.chrome.com/docs/lighthouse/performance/third-party-summary" rel="noopener noreferrer"&gt;https://developer.chrome.com/docs/lighthouse/performance/third-party-summary&lt;/a&gt; and &lt;a href="https://web.dev/articles/tbt" rel="noopener noreferrer"&gt;https://web.dev/articles/tbt&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Navigation built from buttons is invisible to crawlers
&lt;/h2&gt;

&lt;p&gt;The audit says &lt;code&gt;Links are not crawlable&lt;/code&gt;, which means search engines can't follow your links.&lt;/p&gt;

&lt;p&gt;Crawlers read &lt;code&gt;&amp;lt;a href="..."&amp;gt;&lt;/code&gt;. If your nav is a &lt;code&gt;button&lt;/code&gt; with an &lt;code&gt;on:click&lt;/code&gt; handler that scrolls the page, it isn't a link as far as a crawler is concerned. Your most important entry points simply don't exist in search.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/#whatwedo"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;What we do&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You keep the styling and the smooth scroll. Let CSS handle it with &lt;code&gt;html { scroll-behavior: smooth }&lt;/code&gt; rather than intercepting clicks in JavaScript.&lt;/p&gt;

&lt;p&gt;On one site I counted four real &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt; elements on the whole page; the rest of the navigation was JavaScript. Real links took SEO from 83 to 100.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://developers.google.com/search/docs/crawling-indexing/links-crawlable" rel="noopener noreferrer"&gt;https://developers.google.com/search/docs/crawling-indexing/links-crawlable&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. canonical tags and structured data
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;canonical&lt;/code&gt; tells search engines which URL is the source of truth for a piece of content. Parameterised URLs, language variants and leftover staging environments all make it easy for them to treat the same page as duplicates. Structured data (JSON-LD) is what lets results show details like ratings or service areas.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"canonical"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/page"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:url"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://example.com/page"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One trap here is worth its own paragraph. In Svelte:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"application/ld+json"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That renders the literal string on the page. Users can see &lt;code&gt;{JSON.stringify(data)}&lt;/code&gt; sitting in the markup, because Svelte doesn't evaluate what's inside a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; element. You need &lt;code&gt;{@html ...}&lt;/code&gt;. I found it only because the Facebook pixel logged "Malformed JSON".&lt;/p&gt;

&lt;p&gt;So after adding structured data, open the page in a browser and look at what actually renders. Don't trust the source file.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls" rel="noopener noreferrer"&gt;https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls&lt;/a&gt; and &lt;a href="https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data" rel="noopener noreferrer"&gt;https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Contrast and touch targets
&lt;/h2&gt;

&lt;p&gt;When accessibility stalls at 97 or 98, the culprits are almost always &lt;code&gt;color-contrast&lt;/code&gt; and &lt;code&gt;target-size&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Light grey text on white looks refined, but WCAG AA wants a contrast ratio of 4.5:1, or 3:1 for large text. Tappable things on mobile need to be at least 24×24 px, and putting them too close together also fails.&lt;/p&gt;

&lt;p&gt;Don't judge this by eye. Run Lighthouse once and it tells you the exact values you're missing. Readability beats the elegant-grey look.&lt;/p&gt;

&lt;p&gt;On my own site: &lt;code&gt;#a4a4a4&lt;/code&gt; on &lt;code&gt;#f7f7f7&lt;/code&gt; measured 2.85:1, so it became &lt;code&gt;#767676&lt;/code&gt; at 4.54:1. Footer links were 85×20 px and got padding to clear 24 px.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://web.dev/learn/accessibility/color-contrast" rel="noopener noreferrer"&gt;https://web.dev/learn/accessibility/color-contrast&lt;/a&gt; and &lt;a href="https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html" rel="noopener noreferrer"&gt;https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two honest notes
&lt;/h2&gt;

&lt;p&gt;A perfect Lighthouse score isn't the point. The point is that people see your content within three seconds, can tap what they need, and can find you in search. The score is useful only because it turns those feelings into numbers you can work through and verify one at a time.&lt;/p&gt;

&lt;p&gt;The longest run I've done was eight rounds to take a mobile score from 61 to 86, and that was with every third-party tracking script left in place. Most sites don't need eight rounds. The first three items above will usually cut the page weight in half.&lt;/p&gt;

&lt;p&gt;If you're not sure where your site stands, send me the URL. I'll run it through a tool I built and give you a free diagnosis: why the score is stuck, what's fixable, and which parts are a ceiling set by third-party scripts that no amount of refactoring will move.&lt;/p&gt;

&lt;p&gt;Tool: &lt;a href="https://prismicaudit.com" rel="noopener noreferrer"&gt;https://prismicaudit.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>seo</category>
      <category>a11y</category>
    </item>
  </channel>
</rss>
