<?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>Why every platform's pages score badly on PageSpeed — and why "score" is the wrong thing to look at</title>
      <dc:creator>Cody Wang</dc:creator>
      <pubDate>Tue, 20 Oct 2026 10:11:50 +0000</pubDate>
      <link>https://dev.to/amazong110/why-every-platforms-pages-score-badly-on-pagespeed-and-why-score-is-the-wrong-thing-to-look-at-b9e</link>
      <guid>https://dev.to/amazong110/why-every-platforms-pages-score-badly-on-pagespeed-and-why-score-is-the-wrong-thing-to-look-at-b9e</guid>
      <description>&lt;p&gt;I've spent the last few years doing performance work on Next.js, Prismic, Shopify and WordPress builds. This is what I've learned about why the numbers refuse to agree — and which ones I actually report.&lt;/p&gt;




&lt;h2&gt;
  
  
  The question I get on almost every project
&lt;/h2&gt;

&lt;p&gt;A client once asked me: &lt;strong&gt;"why are our scores so low?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The site felt fast. It looked fast. My own Lighthouse run on my laptop said &lt;strong&gt;98&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;PageSpeed Insights said &lt;strong&gt;71&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Neither number is wrong. But two things nobody had told them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Those two tools do not measure the same thing.&lt;/li&gt;
&lt;li&gt;The score is not a property of their site. It's a &lt;strong&gt;reading&lt;/strong&gt; — taken on one machine, on one network, at one moment.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That second point is the one I now lead with.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. What you are actually measuring
&lt;/h2&gt;

&lt;p&gt;Local Chrome Lighthouse and PageSpeed Insights differ in four ways at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Network.&lt;/strong&gt; Local: your broadband, often partly cached. PageSpeed: fixed &lt;strong&gt;Slow 4G&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU.&lt;/strong&gt; Local: your own machine. PageSpeed: &lt;strong&gt;4× CPU throttling&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache.&lt;/strong&gt; Local: easy to keep warm. PageSpeed: &lt;strong&gt;cold start&lt;/strong&gt;, every run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Geography.&lt;/strong&gt; Local: usually close to the CDN. PageSpeed: Google's lab → your host, so worse &lt;strong&gt;TTFB&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same page, 98 and 71. The gap isn't a bug in either tool — it's four variables changing together, and only one of them is your code.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What I do:&lt;/strong&gt; when I report a number, I say which tool and which profile produced it. A number without that context isn't information.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  2. Three ways I've watched people measure it wrong
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Testing the dev server.&lt;/strong&gt; &lt;code&gt;npm run dev&lt;/code&gt; is uncompressed and running a hot-reload loop. I have seen a mobile score around &lt;strong&gt;52&lt;/strong&gt; on a page that sits in the 80s in production. That's not a baseline, it's noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing in a browser you've already visited.&lt;/strong&gt; Your cache is absorbing costs your visitors will pay. Incognito, cache disabled — or don't trust the result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Testing a deployment that's already been replaced.&lt;/strong&gt; You fixed the code, but the URL you tested is the previous build. This happens constantly with preview deploys.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What I do:&lt;/strong&gt; before I touch anything, I write down the tool, the profile, the URL and the build. Half of all "performance regressions" I've been asked to fix turned out to be one of these three.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  3. Why every platform fails
&lt;/h2&gt;

&lt;p&gt;I work across four stacks, and they break in different ways. But they share one root cause:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Each of these platforms lets code you did not write run on your critical path.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Shopify — third parties dominate the score.&lt;/strong&gt; On a Shopify store the theme is rarely the problem. Booking apps, review widgets, chat, heatmaps, pixels — they inject at runtime and they own the main thread.&lt;/p&gt;

&lt;p&gt;Here is the clearest example I have. A booking app pulled jQuery from a Google CDN. On a weak connection that request was slow or failed outright, and the button sat on "Loading…" forever. The fix wasn't in the app. It was in the theme:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;self-host jQuery in the theme and load it early on product templates&lt;/li&gt;
&lt;li&gt;preconnect to the app's CDN&lt;/li&gt;
&lt;li&gt;disable the sticky add-to-cart that was covering the modal on mobile&lt;/li&gt;
&lt;li&gt;fix the modal's z-index and mobile scroll lock&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the reproduction mattered more than the fix: that missing button only appeared when you navigated from a collection page into a product page on a weak network. Testing the collection page alone proves nothing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What I do:&lt;/strong&gt; on Shopify I test the page where the script actually injects — not the page that's convenient.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;WordPress — plugin sprawl.&lt;/strong&gt; Email, gift cards, forms, payments: each brings its own front-end JavaScript and CSS, and the stack adds up faster than anyone expects. Caching is a trap too — logged-in, cart and checkout pages usually bypass it, so I measure both states and say which one I'm reporting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next.js and content sites — three repeat offenders.&lt;/strong&gt; The same asset downloaded twice (a mobile-only and a desktop-only &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; of the same file); a CSS &lt;code&gt;background-image&lt;/code&gt; that only becomes discoverable after the stylesheet parses; too much JavaScript in the first-load packet.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What I do:&lt;/strong&gt; on content sites I ask one question first — &lt;strong&gt;is the LCP element discoverable in the initial HTML?&lt;/strong&gt; In most of my slowest cases, it wasn't.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  4. Where the points actually are
&lt;/h2&gt;

&lt;p&gt;Approximate Lighthouse 10 performance weights:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TBT — about 30%.&lt;/strong&gt; The main thread is blocked by long tasks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LCP — about 25%.&lt;/strong&gt; The largest element paints late.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLS — about 25%.&lt;/strong&gt; The layout jumps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FCP — about 10%.&lt;/strong&gt; First contentful paint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SI — about 10%.&lt;/strong&gt; Visual completeness.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;TBT plus LCP is more than half the score.&lt;/strong&gt; When I'm asked to "fix performance", I work on those two and I say so out loud. Spending a week on FCP and Speed Index is working on 20% of the problem.&lt;/p&gt;

&lt;p&gt;And I do it on mobile. Desktop scores look fine and hide all of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Two things that still surprise people
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A solid background colour is not FCP.&lt;/strong&gt; First Contentful Paint needs actual content — text, an image, a gradient, an SVG. Paint a page with one background colour and it has no first contentful paint at all, however good it looks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;font-display: optional&lt;/code&gt; can win you CLS and LCP&lt;/strong&gt; — on a fast connection. On a slow one, the &lt;strong&gt;entire visit&lt;/strong&gt; stays in the fallback font. To the client that isn't a trade-off I chose; it looks like a bug.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. The uncomfortable part I now say early
&lt;/h2&gt;

&lt;p&gt;Here is a real chain from one project, in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Local broadband Lighthouse: up to &lt;strong&gt;98&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;PageSpeed Insights, mobile: &lt;strong&gt;71–75&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Temporary image downsizing: &lt;strong&gt;~91&lt;/strong&gt; — rejected by the client as blurry&lt;/li&gt;
&lt;li&gt;High-quality imagery restored: settled at &lt;strong&gt;82–85&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I say this at the start of the project now:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;With "no quality loss, no structural change" as a hard requirement, a mobile score rarely locks at 90.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Something has to give: bytes, motion, or images. My job is to tell you which one I'd give up and what it costs. &lt;strong&gt;If nobody agrees on that, we will argue about the score forever — and the score is the one number that depends on the machine it was measured on.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  7. What I report instead
&lt;/h2&gt;

&lt;p&gt;I stopped treating the score as the goal. It's an environment-dependent reading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Page weight and LCP are properties of the page itself.&lt;/strong&gt; They travel. They are the same whether Google or I run the test. So those are what I measure, and I show before and after, taken back to back on the same machine.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A score tells you how one machine on one network felt about your page.&lt;br&gt;
Page weight and LCP tell you what your page actually is.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  8. Three checks you can run yourself
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;DevTools → Network → Img, then reload.&lt;/strong&gt; Find the largest image. Compare the file's real width with the size it's displayed at. If the file is 2–3× bigger than the slot, that's your first win — and it's usually the cheapest one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run Lighthouse mobile three times and report the median.&lt;/strong&gt; Never a single run. A single run is a coin flip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search your HTML for two &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tags pointing at the same file.&lt;/strong&gt; That's a duplicate download, and it costs nothing to fix.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The one-line version
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Measure production, not your dev server. Make the LCP element discoverable and single-request. Keep first-load JavaScript and fonts off the critical path. Remember a solid colour isn't FCP. Test Shopify apps on the page they actually load on. And agree the quality trade-off before you promise anyone a number.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;I build and fix content-driven front-ends — Next.js, Prismic, Shopify and WordPress — and I measure the result instead of asserting it. If you want to know whether your site is actually slow, or just being measured badly, run it through &lt;a href="https://prismicaudit.com" rel="noopener noreferrer"&gt;prismicaudit.com&lt;/a&gt; and send me what it finds.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>seo</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>An AI cleanup deleted 8 lines of CSS. The build passed. The signboard vanished.</title>
      <dc:creator>Cody Wang</dc:creator>
      <pubDate>Tue, 20 Oct 2026 09:20:39 +0000</pubDate>
      <link>https://dev.to/amazong110/an-ai-cleanup-deleted-8-lines-of-css-the-build-passed-the-signboard-vanished-19ii</link>
      <guid>https://dev.to/amazong110/an-ai-cleanup-deleted-8-lines-of-css-the-build-passed-the-signboard-vanished-19ii</guid>
      <description>&lt;p&gt;A few weeks ago I asked an agent to tidy the token layer of a restaurant template. It did exactly what I asked. One theme block had eight custom properties that looked unused: three wood tones, one border shade, one inset shade, and three paper tones for a signboard face. Nothing in the React code referenced them by name. The agent deleted all eight and reported "removed unused variables."&lt;/p&gt;

&lt;p&gt;The build passed. Typecheck passed. No console error. The page still rendered.&lt;/p&gt;

&lt;p&gt;The hand-painted A-frame sign at the top of the page — the one element a customer would actually photograph — was now a blank, transparent rectangle. Every gradient on it resolved to nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why nothing caught it&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CSS custom properties are strings. &lt;code&gt;var(--wood-2)&lt;/code&gt; with no definition is not a syntax error; it's invalid at computed-value time, so the property falls back to unset — usually transparent.&lt;/li&gt;
&lt;li&gt;TypeScript doesn't read CSS. ESLint doesn't know your design tokens. Neither does the bundler.&lt;/li&gt;
&lt;li&gt;Lighthouse is happy. Zero errors, zero warnings. The page is still "accessible" and still "fast."&lt;/li&gt;
&lt;li&gt;"Unused" had been judged by grepping the TSX. The tokens were consumed in a different language — inside &lt;code&gt;linear-gradient()&lt;/code&gt; and layered &lt;code&gt;box-shadow&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the shape of what disappeared, from the theme block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-theme&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"estuario"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--wood-1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#8b5a2b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="py"&gt;--wood-2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#6e4320&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="py"&gt;--wood-3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#a67c52&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--wood-border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#5c3a1e&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="py"&gt;--wood-inset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#3d2412&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--board-face-a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#f3ebe0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="py"&gt;--board-face-b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#e8dcc8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="py"&gt;--board-face-c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#d9c9b0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight lines. The sign was built from all eight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I changed in my process&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The token layer is additive-only. Deleting from it requires a human diff review, even at eight lines.&lt;/li&gt;
&lt;li&gt;Every change ships with before/after screenshots of the section it touched, at 390px and 1440px — not a screenshot of the homepage.&lt;/li&gt;
&lt;li&gt;I review by blast radius, not diff size. Those eight deleted lines touched every surface on that page. A 300-line new component touches one section.&lt;/li&gt;
&lt;li&gt;Before any deletion, the agent has to say what consumes it, with a grep across CSS, TSX, and JSON. If it can't produce that, it doesn't delete.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The part that generalizes&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Agents are strong at additive work and unreliable at cleanup, because cleanup requires knowing the blast radius — and the blast radius is precisely what isn't in the file being edited. A deletion is a claim about the whole repo. Most of the time nobody checks that claim.&lt;/p&gt;

&lt;p&gt;So my rule is boring: additions get reviewed by tests, deletions get reviewed by eyes. Build passing means nothing here — it passed the whole time the sign was gone.&lt;/p&gt;

&lt;p&gt;If you're shipping sites with agents, the review is the product. The 17 templates I've shipped all clear the same gates — zero console errors, no horizontal overflow at 390px, 7:1 contrast, no external image domains — and not one of those gates would have caught this. Only the screenshot did.&lt;/p&gt;

&lt;p&gt;Live pages: &lt;a href="https://slice-templates-pool.vercel.app/" rel="noopener noreferrer"&gt;https://slice-templates-pool.vercel.app/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The broader build, and why the constraint is verification rather than generation speed: &lt;a href="https://dev.to/amazong110/i-built-17-nextjs-homepage-templates-heres-the-part-ai-cant-do-389h"&gt;https://dev.to/amazong110/i-built-17-nextjs-homepage-templates-heres-the-part-ai-cant-do-389h&lt;/a&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>ai</category>
      <category>frontend</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I built 18 Next.js homepage templates. Here's the part AI can't do.</title>
      <dc:creator>Cody Wang</dc:creator>
      <pubDate>Tue, 20 Oct 2026 09:02:41 +0000</pubDate>
      <link>https://dev.to/amazong110/i-built-17-nextjs-homepage-templates-heres-the-part-ai-cant-do-389h</link>
      <guid>https://dev.to/amazong110/i-built-17-nextjs-homepage-templates-heres-the-part-ai-cant-do-389h</guid>
      <description>&lt;p&gt;I spent the last few weeks building 18 homepage templates for Next.js. Seventeen industries — law, dental, barbering, architecture, jewellery, veterinary, cafés, boutique stays, pilates studios, residential contractors — not 18 recolors of the same hero.&lt;/p&gt;

&lt;p&gt;I used AI heavily for all of it. Here is what it was genuinely good at, and the three places it kept failing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it's good at:&lt;/strong&gt; scaffolding. Section skeletons, Tailwind classes, responsive breakpoints, alt text, the boring 60%. A layout that used to take an afternoon takes forty minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it can't do — one: decide what the page is arguing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every homepage makes a claim. A law firm's page argues "we are careful." A salon's argues "you will feel good walking out." AI will happily generate a hero for both and make them look identical. I had to write the claim down first, in one sentence, or the output was decoration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two: spacing is a judgment, not a value.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AI produces &lt;code&gt;py-16&lt;/code&gt; everywhere because it's safe. A page where every section has the same rhythm reads as a template — which is exactly what buyers are trying to escape. Half my time went into rhythm: a tight block after a large one, a quiet section before the CTA.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three: it breaks things it wasn't asked to touch.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ask for one section, get eight files changed. In one case a "cleanup" removed a CSS variable and silently killed a decorative element. It built fine. Type-checked fine. I only caught it by screenshotting before and after on a phone.&lt;/p&gt;

&lt;p&gt;So my workflow is boringly manual: read the diff, change one file, screenshot at 390px, repeat. Agents write fast; the review is the work.&lt;/p&gt;

&lt;p&gt;Every template has to clear the same gates before I ship it: zero console errors, no horizontal overflow at 390px, text contrast at least 7:1, no external image domains. Boring checks, and they're the reason the templates are usable rather than just pretty.&lt;/p&gt;

&lt;p&gt;If you're using agents for frontend, the constraint isn't generation speed anymore. It's how fast you can verify.&lt;/p&gt;

&lt;p&gt;I wrote up one specific failure from that build — an agent "cleanup" that deleted eight CSS variables and made a signboard disappear while every check stayed green: &lt;a href="https://dev.to/amazong110/an-ai-cleanup-deleted-8-lines-of-css-the-build-passed-the-signboard-vanished-19ii"&gt;https://dev.to/amazong110/an-ai-cleanup-deleted-8-lines-of-css-the-build-passed-the-signboard-vanished-19ii&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The live pages (and the source) are here: &lt;a href="https://slice-templates-pool.vercel.app/" rel="noopener noreferrer"&gt;https://slice-templates-pool.vercel.app/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>ai</category>
      <category>frontend</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The link buried in your dropdown is costing you twice — once in clicks, once in search</title>
      <dc:creator>Cody Wang</dc:creator>
      <pubDate>Sat, 17 Oct 2026 02:26:21 +0000</pubDate>
      <link>https://dev.to/amazong110/the-link-buried-in-your-dropdown-is-costing-you-twice-once-in-clicks-once-in-search-337e</link>
      <guid>https://dev.to/amazong110/the-link-buried-in-your-dropdown-is-costing-you-twice-once-in-clicks-once-in-search-337e</guid>
      <description>&lt;p&gt;Every navigation bar is an argument about what matters. Most of them are made by accident.&lt;/p&gt;

&lt;p&gt;I worked on a site where one section — a genuinely useful one, with real content behind it — sat two levels down. You had to hover a top-level item, wait for a panel, and find it in a list of eight. Nobody had decided that page didn't matter. It just ended up there, the way things do.&lt;/p&gt;

&lt;p&gt;I moved that one link out of the dropdown and into the top bar. Same label, same page, same design system, same week.&lt;/p&gt;

&lt;p&gt;That single move did two jobs at once. One is the obvious one — people finally see it. The other is quieter and most people never connect it: &lt;strong&gt;it changes what your site tells Google about that page.&lt;/strong&gt; If you only think about the first, you're leaving half the value on the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a buried link doesn't get clicked
&lt;/h2&gt;

&lt;p&gt;Three things change when a link leaves a dropdown, and only one of them is about "visibility."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A dropdown is not scanned, it's avoided.&lt;/strong&gt; People read a horizontal nav in a single sweep, left to right, in well under a second. A dropdown demands a deliberate action — hover or tap, wait, then read a new list — before the user knows whether the payoff exists. That's a cost paid on speculation. Most people decline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A list of eight flattens everything inside it.&lt;/strong&gt; In a dropdown, your item is one of eight peers. No hierarchy, no emphasis, no visual weight — a line of text among lines of text. On the top level, it inherits the bar's salience and becomes one of four or five things the site is &lt;strong&gt;about&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Position is a claim about importance.&lt;/strong&gt; Users infer priority from placement, because that's how every interface they've ever used works. Putting something in the top bar tells them it's a main line of the business without a word of copy. That inference does most of the work — which is why the same link in the same font performs completely differently one level up.&lt;/p&gt;

&lt;p&gt;Notice what's missing from that list: the label. If the label had been the problem, moving it would have fixed nothing.&lt;/p&gt;

&lt;p&gt;And on phones it's worse. A hover dropdown becomes a tap, a tap becomes a full-screen accordion, and the user has left the homepage before they've seen your section at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the client actually gets
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The section stops being a secret.&lt;/strong&gt; This is the whole point. A page nobody can find isn't a page — it's a URL. Moving it up is the cheapest way to turn an asset you already paid for into something that earns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A shorter path, and shorter paths convert.&lt;/strong&gt; The move removes one deliberate decision between a visitor and the thing they came for. Every extra step in a journey is a place where people leave, and a dropdown is the step with the worst attrition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A positioning statement that costs nothing to make.&lt;/strong&gt; Putting something in the top bar tells every visitor, on every page, that this is part of what the business does. That's brand work, and you get it for the price of a menu edit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A mobile experience that isn't broken.&lt;/strong&gt; On small screens a deep item can be three interactions away. Promoting it often fixes mobile discovery without a redesign.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And a decision that forces the question.&lt;/strong&gt; A nav bar is zero-sum — the top level fits four or five items and then it doesn't. So promoting one thing means something else steps down. That's not a downside; it's the useful part. It forces you to answer, out loud, what this business is actually selling. If you can't answer it, the nav was never the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this also helps search — and why almost nobody says so
&lt;/h2&gt;

&lt;p&gt;Here's the part that gets skipped. When you move a link into the sitewide top bar, you're not just changing what humans see. You're changing what your site tells a crawler on every single page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your navigation is your strongest internal anchor text.&lt;/strong&gt; Every page on the site now links to that section with the same words. That repetition is one of the clearest topical signals you can send about what a page is about. If the item lived under a generic label — "More", "Resources", "Solutions" — the anchor text it inherited was generic, and so was the signal. Give it a top-level label that matches what people actually call the thing, and the signal sharpens on every page at once. This is the single most underrated reason to promote a link, and it costs nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Crawl depth is a budget.&lt;/strong&gt; Search engines don't crawl every URL equally often; pages linked from every page of the site get re-crawled more regularly than pages three levels deep. That matters for two practical reasons: new content gets discovered and indexed sooner, and changes you make to an existing page get picked up faster. A buried section can sit with stale titles and old copy in the index for weeks because nothing is prompting a re-crawl.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It makes the hierarchy legible.&lt;/strong&gt; A sitewide link communicates "this is a top-level part of this site." That's a structural statement, and structure is one of the few things about your site you fully control. Pages that are one click from everywhere tend to earn internal recognition — including, sometimes, sitelinks under your main result in search. Google decides those, not you, but it can only choose from what your structure makes obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And there's a trap worth checking this week.&lt;/strong&gt; Plenty of dropdown menus are built so the links only exist after JavaScript runs — the markup is generated on hover. If that's your menu, a crawler that doesn't execute scripts sees nothing at all, and the section is invisible to search even though a human can reach it. The test takes ten seconds: open the page, view the raw HTML source, and search for the link's text. If it isn't there, your navigation is smaller than it looks.&lt;/p&gt;

&lt;p&gt;Two honest caveats. Moving a link up does not, on its own, move rankings — it improves discoverability and strengthens internal signals, and those compound over time. And none of this fixes a page that doesn't deserve the traffic. Promotion buys attention; the page has to be worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to move it
&lt;/h2&gt;

&lt;p&gt;If the page is thin, if it's a legal page, if it's a campaign that ends next month, or if promoting it means demoting something that actually pays the bills — leave it where it is. A top bar with nine items is worse than a dropdown, because then nothing is important.&lt;/p&gt;

&lt;p&gt;The rule I use: the top bar should contain the things you'd say out loud if someone asked what the business does. Everything else can live one level down and still be found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-minute check
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open the page. Is it reachable from the top bar in one action?&lt;/li&gt;
&lt;li&gt;View the raw HTML and search for the link's text. If it's not in the source, crawlers may not see it either.&lt;/li&gt;
&lt;li&gt;Count the items in your top bar. More than five means the argument about what matters has already been lost.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;If you want a second opinion on your navigation, tell me what's in your top bar and what's one level down. I'll tell you which one I'd move, which one I'd cut, and which one is invisible to search without you knowing.&lt;/p&gt;




&lt;p&gt;If you'd rather have someone look at it directly: this is the kind of thing I do for a living — &lt;a href="https://prismicaudit.com/?from=devto" rel="noopener noreferrer"&gt;prismicaudit.com&lt;/a&gt;. Tell me what's in your top bar and what's one level down, and I'll tell you which one I'd move and which one I'd cut.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>ux</category>
      <category>marketing</category>
    </item>
    <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>
