<?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: Ted</title>
    <description>The latest articles on DEV Community by Ted (@henry_dan_81513dd35a2f540).</description>
    <link>https://dev.to/henry_dan_81513dd35a2f540</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%2F2256153%2Fd3b27e5a-9e82-4c4d-b481-9b835d4deea3.png</url>
      <title>DEV Community: Ted</title>
      <link>https://dev.to/henry_dan_81513dd35a2f540</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/henry_dan_81513dd35a2f540"/>
    <language>en</language>
    <item>
      <title>You Deleted It. The Crawler Still Sees It.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Sun, 19 Jul 2026 07:56:19 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/you-deleted-it-the-crawler-still-sees-it-183c</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/you-deleted-it-the-crawler-still-sees-it-183c</guid>
      <description>&lt;p&gt;I removed two listings from a directory site I run. Not for a small reason — they failed the one promise the directory exists to make. The kind of removal you do immediately and double-check.&lt;/p&gt;

&lt;p&gt;I did double-check. Flipped the flag in the database, loaded the site, watched the queries filter them out. Gone from the city pages, gone from the main directory. Removal confirmed.&lt;/p&gt;

&lt;p&gt;A day later, both were still on the site.&lt;/p&gt;

&lt;p&gt;Not on some cached page or stale CDN edge. In the current, freshly deployed HTML — sitting in the exact lists I had watched them disappear from. I had verified the deletion in one document. The site is two.&lt;/p&gt;

&lt;h2&gt;
  
  
  A rendered site is two documents
&lt;/h2&gt;

&lt;p&gt;Here's the architecture, because the bug lives in it. The site is a single-page app: the browser downloads a JavaScript bundle, the bundle fetches data, the page assembles itself client-side. Crawlers are historically bad at waiting around for that, so — like a lot of SPA operators — I run a prerender step at build time. A script fetches the same data, bakes real content into each route's static HTML, and &lt;em&gt;that&lt;/em&gt; is what robots, link previews, and anyone with broken JavaScript actually receives. The bundle takes over afterward and re-renders everything live.&lt;/p&gt;

&lt;p&gt;It works. It's also a decision with a consequence I under-appreciated for months: &lt;strong&gt;every fact on the site now exists twice.&lt;/strong&gt; Once in the live render, computed in the browser from a fresh query. And once in the baked copy, computed at build time by a different script, from a different query, through a different code path.&lt;/p&gt;

&lt;p&gt;Two documents. Two authors. One URL.&lt;/p&gt;

&lt;p&gt;And the two authors don't consult each other. The browser filters listings with a &lt;code&gt;WHERE allowed = true&lt;/code&gt;. The build script — written earlier, for a different purpose — never selected that column. So when I "deleted" the two properties, the browser's query dutifully dropped them, and the build script kept baking both into the static HTML on every deploy, complete with links and ratings. From its perspective, the deletion had never happened.&lt;/p&gt;

&lt;p&gt;The deletion was real in one document and fiction in the other. Nothing errored, because nothing was wrong: both documents were internally consistent, both rendered cleanly, both passed every check that looks at documents one at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three divergences, one day
&lt;/h2&gt;

&lt;p&gt;Once I knew to look for the split, I found it three times in a single day — same site, escalating stakes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The title fix that wasn't.&lt;/strong&gt; A page was earning search impressions for a phrase its title didn't contain, so I added the phrase. Checked the page in the browser: there it was, in the tab, in the markup. But client-side titles are set by the bundle &lt;em&gt;after&lt;/em&gt; load — the crawler reads the baked &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, and the baked title still didn't have the phrase. The fix was fully live for every human and invisible to the one reader that determines whether the page ranks. It had been "fixed" in the wrong document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The labels that didn't propagate.&lt;/strong&gt; I'd spent that week making the site more honest — replacing a blanket "verified" badge with per-listing labels that say what's actually confirmed versus merely claimed. The app rendered the new labels beautifully. The baked copy went right on telling crawlers every listing was verified, in a header the build script had been stamping onto nine different pages. The honesty overhaul shipped to the browser and not to the record.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The deletion.&lt;/strong&gt; The first two failures overstate; this one &lt;em&gt;resurrects&lt;/em&gt;. A removed listing isn't a stale adjective. It's an entity I had decided, for cause, should not be presented — still being presented, by a document I'd forgotten was the one that counts.&lt;/p&gt;

&lt;p&gt;And the failure runs in both directions. While fixing the build script I added a column to its fetch that didn't exist in that table. The data API rejected the query — and the script, hitting an error, quietly returned an empty list, at which point every detail page fell back to a generic template. No build failure. No warning. Just a deploy where the baked document silently regressed while the live one stayed perfect. Two documents means twice the ways to be wrong, and each one's failures are invisible from inside the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing catches this
&lt;/h2&gt;

&lt;p&gt;Every tool I had was pointed at one document or the other. Never at the seam.&lt;/p&gt;

&lt;p&gt;The type checker checks the app. The tests exercise the app. The browser — where all manual verification happens, because that's where &lt;em&gt;you&lt;/em&gt; look at your site — runs the app. Meanwhile the build logs confirm the prerender ran, which is not the same as confirming what it wrote. The bug wasn't in either document. It was in the fact that there were two.&lt;/p&gt;

&lt;p&gt;This is the oldest failure shape in data systems — the same fact stored in two places will drift, given time — wearing a disguise that makes it easy to miss: the two copies here aren't two database rows or two config files. They're two &lt;em&gt;renderings of the same URL&lt;/em&gt;, which is precisely why it doesn't feel like duplication. It feels like one page. You'd never think to diff a page against itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Done" names a document
&lt;/h2&gt;

&lt;p&gt;The fix for the specific bugs was mundane: make the build fetch select the flag, filter on it, propagate the labels, correct the title — in the baked layer, where each change should have landed first. The durable fix was changing what I accept as evidence that a change shipped.&lt;/p&gt;

&lt;p&gt;Looking at the site in a browser verifies the live document. It verifies nothing about the baked one. So the definition of done, for anything that matters to a crawler — a title, a claim, a listing's existence — is now mechanical: fetch the served HTML the way a robot would, with no JavaScript executed, and confirm the change is in the bytes. &lt;code&gt;curl&lt;/code&gt;, grep, done. Ten seconds. It would have caught all four incidents before deploy, and it's the only check that examines the document I kept forgetting existed.&lt;/p&gt;

&lt;p&gt;If your site renders twice, you don't have a page. You have a page and its understudy, and the understudy is the one performing for the audience that decides whether anyone finds you. Verify the performer that's actually on stage.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Further reading&lt;/strong&gt; — adjacent failure modes from the same system: &lt;a href="https://tedagentic.com/posts/prerender-silent-failure" rel="noopener noreferrer"&gt;the prerender pipeline that ran on every deploy while writing empty pages&lt;/a&gt;, and &lt;a href="https://tedagentic.com/posts/prose-is-a-copy-too" rel="noopener noreferrer"&gt;prose as one more unsynchronized copy of your data&lt;/a&gt;. Same lesson at different layers: anything that exists twice, drifts.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://tedagentic.com/posts/you-deleted-it-the-crawler-still-sees-it" rel="noopener noreferrer"&gt;tedagentic.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>javascript</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The Listing Didn't Exist. The Booking Link Worked.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Sat, 18 Jul 2026 18:55:00 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/the-listing-didnt-exist-the-booking-link-worked-2i4d</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/the-listing-didnt-exist-the-booking-link-worked-2i4d</guid>
      <description>&lt;p&gt;The property was called something plausible. It had a star rating, a price, a tidy paragraph of description, valid &lt;code&gt;LodgingBusiness&lt;/code&gt; structured data, and a green "Verified" badge. It also had a &lt;strong&gt;Book Now&lt;/strong&gt; button wired to a working affiliate link — the kind that pays a commission if someone clicks through and reserves a room.&lt;/p&gt;

&lt;p&gt;The property did not exist. Not "closed," not "renamed." It was never real. Some fast, AI-assisted build had generated it whole — name, blurb, rating, amenities — and pointed it at a live payment rail.&lt;/p&gt;

&lt;p&gt;It wasn't alone. There was a whole cohort of them, and the giveaway was almost funny: a dozen distinct "hotels" in different cities all sharing the &lt;em&gt;same&lt;/em&gt; affiliate link. Different names, different descriptions, one destination URL. A forger who signed every painting with the same signature.&lt;/p&gt;

&lt;p&gt;None of them were broken. That's the part worth sitting with. They passed every automated check I could throw at them. Valid types. Valid schema. No 404s, no build errors, no lint warnings. A validator confirmed they were well-formed, and being well-formed was the entire disguise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong implies there's a real thing being described
&lt;/h2&gt;

&lt;p&gt;I'd spent earlier weeks fixing data that was &lt;em&gt;wrong&lt;/em&gt; — a fact gone stale, an entity filed in the wrong city, a corrected value that never reached the page. Wrong is a solved kind of problem. Wrong means there's a true answer and the stored one disagrees. You audit, you compare against a source, you reconcile.&lt;/p&gt;

&lt;p&gt;This was different, and it took me a beat to see why. You cannot reconcile a fabrication against a source, because there is no referent. The row doesn't describe a real thing incorrectly. It describes nothing, confidently. There's no fact to check because there's no subject.&lt;/p&gt;

&lt;p&gt;So the first reframe: &lt;strong&gt;AI-generated seed data isn't a rough draft you refine. It's a forgery you have to detect&lt;/strong&gt; — and its defining property is that it validates. Every instinct we've built says malformed data is the dangerous data, because malformed data is what breaks things. But malformed data announces itself. The genuinely dangerous record is the one that is perfectly formed and completely false, because nothing in your pipeline is designed to doubt a row that parses.&lt;/p&gt;

&lt;p&gt;I deleted them. You don't refactor a forgery.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quieter version of the same problem
&lt;/h2&gt;

&lt;p&gt;The fake listings were the loud case. Underneath them was a quieter one that had spread much further, and it's the part that actually changed how I think.&lt;/p&gt;

&lt;p&gt;Real listings had a policy field — a small piece of data recording whether a given property had actually been confirmed to permit a certain thing on-site. Most of them had never been confirmed. The field was empty.&lt;/p&gt;

&lt;p&gt;Here is what the page rendered for an empty field: a green &lt;strong&gt;Verified&lt;/strong&gt; badge. A "Yes" in the amenities grid. A line of structured data asserting the thing was permitted. A confident sentence in the server-rendered copy that a crawler reads before any of the interactive parts load.&lt;/p&gt;

&lt;p&gt;Nobody decided to claim these things. There was no bad actor and no bad data. There was one line of code, repeated in spirit across the codebase, that read roughly: &lt;em&gt;if this value is missing, treat it as true.&lt;/em&gt; A default. &lt;code&gt;?? true&lt;/code&gt;, or its cousin in four other files.&lt;/p&gt;

&lt;p&gt;That is the second reframe, and it's the one I'd tattoo on a new engineer: &lt;strong&gt;a default is a claim.&lt;/strong&gt; When you fill an unknown with the favorable value, you are not being helpful and you are not leaving it blank. You are asserting a fact you do not have. The empty field didn't stay empty on the way to the user. It was quietly promoted to a promise.&lt;/p&gt;

&lt;p&gt;Absence rendered as a yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The missing value was "I don't know"
&lt;/h2&gt;

&lt;p&gt;Once I saw it in one place I saw it everywhere, and they all traced to the same root cause. The data model had two states where it needed three.&lt;/p&gt;

&lt;p&gt;There was &lt;code&gt;true&lt;/code&gt;, there was &lt;code&gt;false&lt;/code&gt;, and there was &lt;code&gt;null&lt;/code&gt; — but &lt;code&gt;null&lt;/code&gt; was never a state of its own. It was an &lt;em&gt;input to be resolved&lt;/em&gt;, and every layer resolved it the same optimistic way. The card coerced it to yes. The badge coerced it to yes. The structured data coerced it to yes. The prerendered copy a crawler reads coerced it to yes. One missing fact became four confident assertions, none of which had any way to represent the honest answer.&lt;/p&gt;

&lt;p&gt;Because there was no honest answer available. "Unverified" wasn't a value the system could hold. And a system that cannot represent "I don't know" does not fall silent about what it doesn't know. It reaches for the most flattering value in range and commits.&lt;/p&gt;

&lt;p&gt;The fix was not clever. It was to add the value that was missing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A real, explicit state for &lt;em&gt;unconfirmed&lt;/em&gt; — stored, not inferred.&lt;/li&gt;
&lt;li&gt;A three-way rendering instead of two: &lt;strong&gt;Yes&lt;/strong&gt;, &lt;strong&gt;No&lt;/strong&gt;, and &lt;strong&gt;Unconfirmed&lt;/strong&gt;. The unconfirmed state gets its own muted styling — never the green of a confirmed yes, and just as importantly never the red of a confirmed no. "We haven't checked" is a different claim from "this is not allowed," and collapsing them is its own kind of lie, pointed the other direction.&lt;/li&gt;
&lt;li&gt;Every layer taught to defer to that state instead of defaulting past it. The card, the badge, the structured data, the static copy the crawler reads — all four now say "unconfirmed" together, or say nothing, rather than four independent optimistic guesses.&lt;/li&gt;
&lt;li&gt;Positive claims gated behind an actual confirmed value, so the system asserts a thing is true only when something actually backs it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Give the model a way to shrug
&lt;/h2&gt;

&lt;p&gt;The lesson generalizes past any one field or any one site. It applies anywhere a system holds facts it can't fully back — which is everywhere, and increasingly so as more of the initial data gets generated rather than entered by someone who checked.&lt;/p&gt;

&lt;p&gt;We tend to think of honesty in a system as the &lt;em&gt;absence&lt;/em&gt; of false statements — no bugs, no stale rows, a clean audit. But you can pass every audit and still have a system that lies, because the lies aren't in the data. They're in the defaults. They're in what happens to a value you never had.&lt;/p&gt;

&lt;p&gt;Real honesty is the &lt;em&gt;presence&lt;/em&gt; of "unknown" as a first-class thing: a value you can store, a state you can render, a claim your structured data is allowed to decline to make. If the only options your model offers are yes and no, then every gap in your knowledge silently becomes whichever one flatters you — and the extreme end of that gradient is a hotel that doesn't exist with a working button to book it.&lt;/p&gt;

&lt;p&gt;Give the model an explicit way to say "I don't know." Otherwise it will nod at everything, and you won't find out where until someone audits the room and discovers half of it was painted by the same forger.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Further reading&lt;/strong&gt; — the adjacent failure modes: &lt;a href="https://tedagentic.com/posts/ai-confidence-gap-production-sites" rel="noopener noreferrer"&gt;why AI tools produce confident silent errors on production sites&lt;/a&gt;, and &lt;a href="https://tedagentic.com/posts/fallback-chain-error-suppression" rel="noopener noreferrer"&gt;why a fallback chain hides missing data by design&lt;/a&gt;. This post is the layer under both: why the gap gets filled with a lie in the first place.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://tedagentic.com/posts/the-listing-didnt-exist" rel="noopener noreferrer"&gt;tedagentic.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Logging Into the Higgsfield CLI on a Server With No Browser</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Wed, 08 Jul 2026 04:08:43 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/logging-into-the-higgsfield-cli-on-a-server-with-no-browser-34gi</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/logging-into-the-higgsfield-cli-on-a-server-with-no-browser-34gi</guid>
      <description>&lt;p&gt;I wanted the &lt;a href="https://github.com/higgsfield-ai/cli" rel="noopener noreferrer"&gt;Higgsfield CLI&lt;/a&gt; on my server — a terminal front-end to a pile of image and video generation models, which makes it scriptable and cron-able in a way the web UI never will be. The install was one line. Then it asked me to log in, and a specific, recurring failure showed up that has nothing to do with Higgsfield and everything to do with a word that means two different things at once.&lt;/p&gt;

&lt;p&gt;The word is &lt;code&gt;localhost&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The server is headless: no monitor, no browser, reached only over SSH from a laptop. That detail is the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flow that assumes a lap
&lt;/h2&gt;

&lt;p&gt;Higgsfield authenticates with OAuth 2.0 PKCE, the way most modern CLIs do, and the polite version goes like this: the CLI opens a small HTTP listener on &lt;code&gt;localhost:&amp;lt;port&amp;gt;&lt;/code&gt;, prints a login URL, and waits. You open the URL in a browser, approve, and the identity provider (Higgsfield fronts theirs with Clerk) redirects your browser to &lt;code&gt;http://localhost:&amp;lt;port&amp;gt;/callback?code=...&lt;/code&gt;. The listener catches that request, reads the code out of it, trades it for a token, and you're in.&lt;/p&gt;

&lt;p&gt;On a laptop this is seamless because the browser and the listener are the same machine. &lt;code&gt;localhost&lt;/code&gt; resolves to the same place for both halves of the handshake. The whole design quietly depends on that.&lt;/p&gt;

&lt;p&gt;On a headless server it falls apart, because the two halves are now on different machines. The listener is on the server. The browser is on my laptop. When Clerk redirects the browser to &lt;code&gt;http://localhost:&amp;lt;port&amp;gt;/callback&lt;/code&gt;, the browser dutifully connects to &lt;em&gt;the laptop's&lt;/em&gt; localhost — where nothing is listening — and shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unable to connect
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The login succeeded. The callback failed. The code was issued — it just landed on the wrong machine's front door.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A loopback OAuth flow assumes the browser and the listener share a host.&lt;/strong&gt; That assumption is invisible on a laptop and load-bearing on a headless box.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tunnel, and the wall the spec put in front of it
&lt;/h2&gt;

&lt;p&gt;The reflex fix is an SSH tunnel. Forward the callback port from the laptop to the server, so the laptop's &lt;code&gt;localhost:&amp;lt;port&amp;gt;&lt;/code&gt; actually reaches the server's listener:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;ssh&lt;/span&gt; -L &lt;span class="m"&gt;8799&lt;/span&gt;:localhost:8799 you@server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sound. It works. But I hit a wall trying to pin the port, and the wall is worth understanding because it's not a bug — it's the security model doing its job.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;higgsfield auth login&lt;/code&gt; has a &lt;code&gt;--port&lt;/code&gt; flag, so I told it to use &lt;code&gt;8799&lt;/code&gt; to match my tunnel. Clerk rejected the whole login:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: invalid_request
The 'redirect_uri' parameter does not match any of the
Client's pre-registered redirect URIs.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the thing PKCE doesn't remove: the &lt;strong&gt;redirect URI allowlist&lt;/strong&gt;. PKCE lets a CLI skip shipping a client secret — the code challenge proves the token request came from whoever started the login — but the provider still refuses to send a code to any callback URL the app didn't register in advance. That allowlist is what stops a malicious page from pointing your authenticated redirect at &lt;em&gt;its&lt;/em&gt; server. Higgsfield registered a specific handful of ports with Clerk, and those are the only ones that work. &lt;code&gt;8799&lt;/code&gt; wasn't in the club.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The redirect URI is a registered constant, not a free parameter.&lt;/strong&gt; You don't get to pick the port; you get to pick from the ports the app's developer already blessed. Drop the &lt;code&gt;--port&lt;/code&gt; flag and the CLI falls back to its own default and it works — but now your tunnel has to match a port you don't fully control, and if that port is already busy on the server (mine collided with another service on the default), the CLI silently falls back again to a &lt;em&gt;different&lt;/em&gt; registered port, and your tunnel is aimed at the wrong one.&lt;/p&gt;

&lt;p&gt;The tunnel isn't wrong. It's just more moving parts than the problem actually needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The callback is just data
&lt;/h2&gt;

&lt;p&gt;The realization that made this easy: &lt;strong&gt;the callback is not a handshake, it's a message.&lt;/strong&gt; It's a plain HTTP GET whose entire payload is visible in the URL — &lt;code&gt;?code=&amp;lt;the code&amp;gt;&amp;amp;state=&amp;lt;the anti-forgery token&amp;gt;&lt;/code&gt;. The listener doesn't care &lt;em&gt;who&lt;/em&gt; delivers that message. It cares that the message arrives with a &lt;code&gt;state&lt;/code&gt; matching the one it issued.&lt;/p&gt;

&lt;p&gt;So I stopped trying to route the browser to the listener at all. I ran &lt;code&gt;higgsfield auth login&lt;/code&gt; on the server (letting it pick its own registered port — it landed on &lt;code&gt;8766&lt;/code&gt;), opened the printed URL in my laptop browser, approved, and let the redirect fail — &lt;code&gt;Unable to connect&lt;/code&gt;, as expected. Then I copied the dead URL straight out of the browser's address bar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://localhost:8766/callback?code=OWVH...F1L&amp;amp;state=kg6P...TCY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That URL is the whole message. The listener is sitting on the server's own localhost, exactly where the CLI is watching. So I replayed it there:&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="s2"&gt;"http://localhost:8766/callback?code=OWVH...F1L&amp;amp;state=kg6P...TCY"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The listener saw a request on its port, checked the &lt;code&gt;state&lt;/code&gt;, matched it, exchanged the code for a token, and printed &lt;code&gt;Successfully authenticated.&lt;/code&gt; No tunnel. The browser did the one thing only a browser with my logged-in Higgsfield session could do — prove I'm me — and then I hand-carried the resulting code the last hop the browser couldn't make.&lt;/p&gt;

&lt;p&gt;Two things make this safe to lean on, and both are worth saying out loud:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;state&lt;/code&gt; parameter is the check that makes replay legitimate.&lt;/strong&gt; The listener issued that random token at the start and refuses any callback that doesn't echo it. I'm not bypassing a security control by replaying the URL — I'm satisfying the exact one the flow was built around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The code is short-lived and single-use.&lt;/strong&gt; An OAuth authorization code is a password with a fuse — good for one exchange, expiring in a minute or two. Copy it, replay it promptly, and don't paste it anywhere it'll be logged. It's not a token you're storing; it's a token you're spending immediately.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd carry to any headless login
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;localhost&lt;/code&gt; is a per-machine word.&lt;/strong&gt; Any auth flow that redirects to &lt;code&gt;localhost&lt;/code&gt; is quietly assuming the browser and the service are the same box. On a headless server they aren't, and that mismatch — not the tool — is your bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect URIs are an allowlist, not a setting.&lt;/strong&gt; PKCE drops the client secret but keeps the registered-callback rule. If pinning a port throws &lt;code&gt;invalid_request&lt;/code&gt;, you're fighting the security model, not a config typo. Use the app's own default port.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A loopback callback is replayable data.&lt;/strong&gt; It's a URL carrying a code and a state. You don't need to &lt;em&gt;route&lt;/em&gt; the browser to the listener; you can let the redirect die and deliver the URL yourself with &lt;code&gt;curl&lt;/code&gt; on the machine that's listening.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;state&lt;/code&gt; is what makes hand-delivery honest.&lt;/strong&gt; It's the flow's own anti-forgery check. Replaying a URL that carries the right &lt;code&gt;state&lt;/code&gt; isn't a workaround around the security — it's the security working.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tunnel is the textbook answer, and some days you'll want it. But when a login on a headless machine tells you it &lt;em&gt;can't connect to localhost&lt;/em&gt;, remember that it already succeeded — the code exists — and the last hop is just a URL you're allowed to carry across yourself. This worked cleanly for Higgsfield; it'll work for any CLI whose login is a loopback redirect, which by now is most of them.&lt;/p&gt;

</description>
      <category>oauth</category>
      <category>devops</category>
      <category>cli</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Both Delivery Paths Went Silent. Neither Was Broken.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Sat, 04 Jul 2026 18:25:22 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/both-delivery-paths-went-silent-neither-was-broken-7cd</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/both-delivery-paths-went-silent-neither-was-broken-7cd</guid>
      <description>&lt;p&gt;My home server sends me a stream of scheduled reports over Telegram every morning — system health, data pulls, monitoring summaries, a wake-up alarm. Around forty-seven cron jobs, delivered through two separate messaging agents, deliberately independent so that one agent failing can't silence the mornings.&lt;/p&gt;

&lt;p&gt;Today the mornings went silent anyway. Both agents. Nothing at 3 AM, nothing at 7, nothing at 8:30. My first assumption was the agents — they're the newest, most complicated part of the stack, and when two things fail at once you suspect the thing they share. I was right about the reasoning and wrong about the layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neither agent was broken
&lt;/h2&gt;

&lt;p&gt;Both gateway services were green — one had been running uninterrupted for a day, the other for longer. But the job telemetry told the real story: &lt;strong&gt;695 failed job executions since 01:03&lt;/strong&gt;, and every single one carried the same last line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Temporary failure in name resolution
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The machine had lost the internet. Not Wi-Fi flapping, not a DNS misconfiguration — even direct queries to &lt;code&gt;1.1.1.1&lt;/code&gt; got nothing, and the LAN link never dropped. The router's upstream was simply dead, from 01:03 to 15:43. Fourteen and a half hours, spanning every scheduled send of the day.&lt;/p&gt;

&lt;p&gt;So the redundancy I'd built was real — two agents, two codepaths, two processes — and also cosmetic. Both paths terminate at the same wall socket. I had redundancy at the application layer and a single point of failure one layer down, which is the kind of thing that's obvious the moment it bites and invisible every day before that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redundancy that shares a dependency is a single point of failure with extra steps.&lt;/strong&gt; Worth auditing for: my two "independent" delivery agents shared the WAN, the DNS resolver, and the power strip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The network came back. Nothing else did.
&lt;/h2&gt;

&lt;p&gt;Here's the part that actually earned this post.&lt;/p&gt;

&lt;p&gt;At 15:43 the internet returned. The every-minute and every-15-minute jobs recovered on their own — their next tick came, succeeded, done. By evening they were all green without anyone touching anything.&lt;/p&gt;

&lt;p&gt;The daily jobs did not come back. The morning report, the data pulls, the summaries — each of them runs once a day, each had fired exactly once into a dead network, and each would not try again until tomorrow. Cron kept perfect time through the whole outage. It just doesn't keep &lt;em&gt;score&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That's the property I'd never had to think about before: &lt;strong&gt;cron remembers nothing.&lt;/strong&gt; A missed tick doesn't queue, doesn't retry, doesn't log a debt. It evaporates. &lt;code&gt;cron&lt;/code&gt; is a metronome, not a ledger — and every recovery guide you'll read is about getting the daemon running again, not about the runs that vanished while it was already running fine.&lt;/p&gt;

&lt;p&gt;So recovery was manual: work out which of the day's jobs had failed inside the window, rerun seven of them by hand — in dependency order, with their exact original command strings so the monitoring would attribute the reruns to the right jobs — and skip the 3 AM alarm, because an alarm at 7 PM isn't late, it's wrong.&lt;/p&gt;

&lt;p&gt;Doing that once is fine. Planning to do it every time the ISP hiccups is choosing to be the retry mechanism yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: give cron a memory
&lt;/h2&gt;

&lt;p&gt;The fix is a small reconciler that runs every 15 minutes and closes the loop cron leaves open. The idea is stolen from control loops everywhere: compare &lt;strong&gt;desired state&lt;/strong&gt; (what the crontab says should have run by now today) against &lt;strong&gt;actual state&lt;/strong&gt; (what the job telemetry says actually succeeded), and converge.&lt;/p&gt;

&lt;p&gt;Every job here already runs through a telemetry wrapper that appends one JSON line per execution — script, start, end, exit code. That log, it turns out, is the missing ledger. The crontab is the promise; the event log is the record; the reconciler is just the diff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sched&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;due_today&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
    &lt;span class="n"&gt;runs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events_today&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;script&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;script&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts_start&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;sched&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;runs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;          &lt;span class="c1"&gt;# promise kept
&lt;/span&gt;    &lt;span class="nf"&gt;rerun&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# promise broken — run the verbatim crontab line, once
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The design decisions matter more than the loop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gate on the network, quietly.&lt;/strong&gt; The first check is a TCP connect to the delivery endpoint. If it fails, the reconciler exits 0 and says nothing — "network down" is a state, not an incident. It will still be here in 15 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only catch up jobs that run once a day or less.&lt;/strong&gt; Anything on an interval self-heals on its next tick; rerunning it is pure noise. Recovery effort should match the job's natural cadence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One attempt per job per day.&lt;/strong&gt; If the rerun also fails, the job is genuinely broken and the existing failure watchdog owns it. A reconciler without an attempt cap is a retry storm with a nicer name.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rerun the verbatim crontab line&lt;/strong&gt; — wrapper, redirects, everything. The telemetry keys runs by command; a rerun that doesn't match the original string shows up as a new mystery job instead of clearing the red one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Denylist the jobs where the time &lt;em&gt;is&lt;/em&gt; the payload.&lt;/strong&gt; The wake-up alarm doesn't get caught up. Late data is still data; a late alarm is a bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse the schedule parser the dashboard already has.&lt;/strong&gt; I nearly wrote a second little cron-expression parser for this and stopped: two parsers means two opinions about what "should have run" means, drifting independently. This was &lt;em&gt;every copy of a fact is a liability&lt;/em&gt; again, wearing a parser costume — the schedule semantics are a fact, and I almost gave them a second copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One accepted gap, named honestly in the code: the reconciler trusts exit codes. A job that exits 0 but fails to deliver its message is invisible to it. The exit code is the contract; scripts that lie about success are a different bug with a different fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd carry to any scheduled system
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cron remembers nothing.&lt;/strong&gt; If a run matters, something other than cron has to remember that it should have happened. A schedule is a promise, not a record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit redundancy for shared dependencies.&lt;/strong&gt; Two of anything that terminate in the same cable is one of that thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-execution telemetry turns out to be the ledger.&lt;/strong&gt; I added the wrapper months ago for a dashboard. It ended up being the thing that made automatic recovery possible — you can't reconcile against a record you never kept.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recovery must distinguish "late" from "wrong."&lt;/strong&gt; Some payloads age gracefully; some are only valid at their scheduled moment. Encode that, or your catch-up mechanism becomes a noise generator.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total build: about 150 lines, one evening, on top of telemetry that already existed. The next 14-hour outage ends with one Telegram message — &lt;em&gt;network back, reran six missed jobs&lt;/em&gt; — instead of a silent morning and an evening of forensics.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>linux</category>
      <category>automation</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Two Pages Disagreed About the Same Law. Both Were Wrong.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Sat, 04 Jul 2026 14:04:07 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/two-pages-disagreed-about-the-same-law-both-were-wrong-276i</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/two-pages-disagreed-about-the-same-law-both-were-wrong-276i</guid>
      <description>&lt;p&gt;Last week I &lt;a href="https://tedagentic.com/posts/legal-data-audit-four-copies" rel="noopener noreferrer"&gt;audited my travel site's core dataset&lt;/a&gt; — legal-status data for 213 countries and every US state — and found the same fact stored in four places, drifting independently. I fixed all four copies, fixed the silent join that kept corrections from rendering, and made the headline counts derive from the data at build time. Audit closed. Confidence high.&lt;/p&gt;

&lt;p&gt;Then I asked the agent to check one blog post — the flagship guide that covers the same topic as the dataset. Just to confirm it matched.&lt;/p&gt;

&lt;p&gt;It didn't. And the ways it didn't match turned out to be worse than anything in the original audit, because this time the wrong facts weren't sitting in a data file waiting for a diff. They were sitting in sentences.&lt;/p&gt;

&lt;h2&gt;
  
  
  The page that argued with itself
&lt;/h2&gt;

&lt;p&gt;The flagship guide is half dynamic, half hand-written. The card grids and comparison tables pull from the data file I'd just fixed — so when the audit shipped, those healed automatically. A Central European country that fully legalized in January showed up in the "Legal" cards the moment the corrected data deployed.&lt;/p&gt;

&lt;p&gt;Three scrolls down, the hand-written FAQ on the &lt;em&gt;same page&lt;/em&gt; said that same country was "decriminalized — not legal," and used it as the textbook example of the difference.&lt;/p&gt;

&lt;p&gt;One page. Two answers. The derived half was right, the asserted half was months behind, and no test could ever catch it because both halves were rendering exactly what they were told to render. There was also a link reading "See all 82 countries" pointing at a page that now lists 155.&lt;/p&gt;

&lt;p&gt;A page that's half-derived and half-asserted will eventually contradict itself. It's not a risk, it's a schedule — the same schedule the four data copies were on, just running through paragraphs instead of rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two pages, opposite lies
&lt;/h2&gt;

&lt;p&gt;That was the warm-up. The real find was a Southeast Asian country that famously opened up in 2022 and has been walking it back since.&lt;/p&gt;

&lt;p&gt;My blog post about it, written in March, said: legal grey zone, walk into any shop, no paperwork needed, here's what to buy.&lt;/p&gt;

&lt;p&gt;My country-profile page about it, updated later, said: fully re-criminalized, every shop closed, zero tolerance, tourists face arrest and four-digit fines — treat it like the strictest country on Earth.&lt;/p&gt;

&lt;p&gt;Same site. Same country. Same month. Opposite advice — and here's the part that matters: &lt;strong&gt;both were wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The verified reality (current primary sources, not model memory) sat exactly between them: the country had gone medical-only in mid-2025 — thousands of licensed shops still open, but buying now requires a prescription any tourist can get in a fifteen-minute consultation at the shop.&lt;/p&gt;

&lt;p&gt;So one page was encouraging illegal behaviour. The other was denying a legal pathway that actually existed. The specifics of the law are just the evidence; either page, followed faithfully, failed the reader.&lt;/p&gt;

&lt;p&gt;If I had noticed the contradiction without checking externally, the obvious move would have been to pick the newer page and sync the older one to it. That would have replaced a dangerous lie with a merely costly one and called it consistency.&lt;/p&gt;

&lt;p&gt;When two of your own copies disagree, neither one is the tiebreaker. Your copies can be wrong in opposite directions, and averaging them or trusting the newer one just launders the error. The only tiebreaker is outside the building.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internal consensus isn't verification.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fabricated statute
&lt;/h2&gt;

&lt;p&gt;After that, I had the agent sweep every post on the site — about fifty-five of them — for anything shaped like a legal claim: statuses, ages, quantities, penalties, years.&lt;/p&gt;

&lt;p&gt;Most survived. One didn't: a travel-safety post claimed a neighboring state had "decriminalized possession under three ounces in 2023," complete with a specific first-offense fine.&lt;/p&gt;

&lt;p&gt;That state never decriminalized anything. It is, per my own freshly-audited dataset, in the strictest tier in the region — any possession is a criminal offense. The claim wasn't stale; there was no year in which it was true. It was invented, presumably by whatever wrote the first draft, and it survived every read-through since because it had everything plausibility needs: a number, a year, a dollar amount.&lt;/p&gt;

&lt;p&gt;In the dataset audit, this failure mode — confident filler for unknown values — was at least constrained by a schema. A status column can only hold one of five values, and thirty identical suspicious values in a row is a visible pattern. Prose has no schema. A fabricated statute sits in a paragraph looking exactly like a researched one, and no uniformity tell will ever expose it. The same sweep also caught a legal age wrong by three years — duplicated, consistently, in two places, which is exactly why duplication felt like confirmation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prose is a copy too
&lt;/h2&gt;

&lt;p&gt;The last audit's lesson was "one fact, four copies." That count was wrong.&lt;/p&gt;

&lt;p&gt;Every sentence on your site that states a fact is another copy of that fact. Unversioned, untyped, invisible to schema checks, excluded from every reconciliation you'll ever write — and, unlike the database, it's the copy your readers and the search engines actually consume. My four-copy inventory missed the largest and least maintained store of facts on the entire site: the writing.&lt;/p&gt;

&lt;p&gt;What actually worked, if you want to reproduce the sweep:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Grep for claim-shaped text.&lt;/strong&gt; Jurisdiction names, numbers with units, permission verbs ("legal," "decriminalized," "banned"), years, fine amounts. Crude, but claims are surprisingly greppable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route every hit against the audited structured data.&lt;/strong&gt; The dataset you just verified is now your answer key for the prose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When your own sources disagree, go external.&lt;/strong&gt; Primary sources settle it — not recency, not confidence, not which page looks more authoritative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix toward derivation.&lt;/strong&gt; The parts of pages that rendered from the data file healed themselves the moment the data was fixed. Every hand-written sentence needed a human-shaped repair. The ratio of derived-to-asserted facts on a page is the ratio of self-healing to permanent liability.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I'd carry to any site
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prose is a copy too.&lt;/strong&gt; Count it in your inventory. It's the biggest copy you have and the only one with no schema to defend it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When two copies disagree, neither is the tiebreaker.&lt;/strong&gt; Verify outside, or you're just choosing which error to standardize on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Half-derived pages contradict themselves on a schedule.&lt;/strong&gt; Either derive the fact everywhere it appears, or accept that every hand-written restatement of it is a new maintenance obligation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plausibility protects fabrications in prose better than in data.&lt;/strong&gt; A schema makes invented values look uniform; a paragraph makes them look researched.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dataset audit took one long session. This follow-up took another — and found the errors with the highest actual cost to a reader, in the layer the first audit had implicitly certified as fine. The data was never the product. The sentences were.&lt;/p&gt;

</description>
      <category>database</category>
      <category>debugging</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Database Was Fixed Months Ago. The Website Disagreed.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Fri, 03 Jul 2026 04:31:21 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/the-database-was-fixed-months-ago-the-website-disagreed-39bh</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/the-database-was-fixed-months-ago-the-website-disagreed-39bh</guid>
      <description>&lt;p&gt;I run a travel site whose core dataset is legal-status data: whether cannabis is recreational, medical-only, decriminalized, or illegal in 213 countries and all 50 US states. Getting one of those entries wrong isn't a typo. Someone plans a trip around it.&lt;/p&gt;

&lt;p&gt;The problem is that this kind of data rots quietly. Laws moved constantly through 2025 and 2026: one US state opened its first dispensaries &lt;em&gt;three weeks&lt;/em&gt; before I ran this audit, a Caribbean nation decriminalized this year, and one country famous for legalizing reversed course almost entirely. A dataset that was accurate when written is a dataset that is wrong now — you just don't know where.&lt;/p&gt;

&lt;p&gt;So I gave an AI agent (Claude Code, in my case) a blunt instruction: go through every state and every country, compare each stored status against current law, and tell me what's wrong. No edits until I approve.&lt;/p&gt;

&lt;p&gt;I expected a list of stale entries. I got that — plus two other categories of wrong I wasn't expecting, and they were both worse. I thought I was auditing facts. It took three kinds of wrong to see I was auditing a system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Way #1 to be wrong: the facts moved
&lt;/h2&gt;

&lt;p&gt;The boring, expected kind. Around a dozen entries where the law simply changed after the data was written.&lt;/p&gt;

&lt;p&gt;Two details from this pile worth keeping:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recency cuts both ways.&lt;/strong&gt; The most out-of-date entry wasn't old — it described a legal regime that ended &lt;em&gt;three weeks earlier&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some "stale" data was never right.&lt;/strong&gt; One US state was labeled decriminalized-only. It has had a medical program &lt;strong&gt;since 2013&lt;/strong&gt;. That error sat on the page through every previous review, because nobody audits the entries that look plausible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fine. This is why you audit. If the story ended here it wouldn't be worth writing up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Way #2 to be wrong: invented by a default
&lt;/h2&gt;

&lt;p&gt;Around thirty small countries and territories — Pacific micro-states, Caribbean islands, a handful of French and Dutch territories — all showed the same status: &lt;strong&gt;Medical&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Almost none of them has any medical program. Several are among the strictest prohibition jurisdictions on Earth.&lt;/p&gt;

&lt;p&gt;The tell was the uniformity. These entries had been bulk-generated at some point, and whoever — or whatever — generated them needed a value for places it knew nothing about. It picked "Medical." Plausible-sounding, middle-of-the-road, and wrong in the one direction that matters: &lt;strong&gt;permissive&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Think about the asymmetry. If you label a legal place "Illegal," the cost is a missed trip and maybe an annoyed email. If you label a strict-prohibition island "Medical," the cost is someone packing something they should never have packed. A default that errs permissive isn't a data bug, it's a liability generator.&lt;/p&gt;

&lt;p&gt;The rule I took from this: &lt;strong&gt;defaults are policy.&lt;/strong&gt; If a generator has to fill in an unknown, it must fill in the strictest value the schema allows — and ideally flag it as unverified. "Unknown" should never round to "kind of allowed."&lt;/p&gt;

&lt;h2&gt;
  
  
  Way #3 to be wrong: fixed, but the page never heard about it
&lt;/h2&gt;

&lt;p&gt;This is the one that made the audit worth it.&lt;/p&gt;

&lt;p&gt;The database had &lt;em&gt;correct&lt;/em&gt; rows for five Caribbean nations — statuses corrected months ago in an earlier data pass. The live page still showed the old, wrong values for three of them. Not stale data. &lt;strong&gt;Correct data that never rendered.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The page builds its country list by merging a static fallback array with the database at runtime. The match was by URL slug or exact name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dbCountry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dbCountries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;staticCountry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
          &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;staticCountry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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;Now look at the actual rows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database says&lt;/th&gt;
&lt;th&gt;Static entry says&lt;/th&gt;
&lt;th&gt;Match?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Saint Lucia&lt;/td&gt;
&lt;td&gt;St Lucia&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Antigua and Barbuda&lt;/td&gt;
&lt;td&gt;Antigua &amp;amp; Barbuda&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saint Vincent&lt;/td&gt;
&lt;td&gt;St Vincent &amp;amp; the Grenadines&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Saint" vs "St". "and" vs "&amp;amp;". Same countries, different spellings, zero matches. When the lookup missed, the code did the reasonable-looking thing — fell back to the static entry. No error. No log line. No way to notice unless you compared what was &lt;em&gt;stored&lt;/em&gt; with what was &lt;em&gt;displayed&lt;/em&gt;, country by country.&lt;/p&gt;

&lt;p&gt;The database corrections had been shipped, reviewed, and celebrated. The page disagreed for months, silently.&lt;/p&gt;

&lt;p&gt;The fix was two parts. First, normalize both sides before comparing — lowercase, strip accents, treat "&amp;amp;" as "and" and "St" as "Saint" — plus an alias map for the two names normalization can't bridge. Second, and more importantly, a test: run every database row against every static entry and print the pairs. Exactly the five broken pairs matched, and nothing else did. A join you haven't tested isn't a join, it's a hope.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real disease: one fact, four copies
&lt;/h2&gt;

&lt;p&gt;Digging into all this surfaced the structural problem underneath. The site held each country's legal status in &lt;strong&gt;four places&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A static fallback array inside the page component&lt;/li&gt;
&lt;li&gt;The database (source of truth, in theory)&lt;/li&gt;
&lt;li&gt;A "rich profiles" data file used for detail rendering&lt;/li&gt;
&lt;li&gt;The site-search index, with its own copy of every status chip&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nobody ever decided "let's keep four copies." Each copy was added for a locally reasonable reason — a fallback for resilience, an index for speed, a profile file for richer fields. That's how it always happens. And with four copies and no reconciliation, drift isn't a risk. It's a schedule.&lt;/p&gt;

&lt;p&gt;The audit method that actually worked, if you want to reproduce it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dump every source.&lt;/strong&gt; All four copies, into flat files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simulate the merge.&lt;/strong&gt; Compute what each entry &lt;em&gt;displays&lt;/em&gt;, not what any store contains. The displayed value is the only value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff against verified current law.&lt;/strong&gt; Web-verify each contested claim against primary sources — more on that below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix all copies in one pass&lt;/strong&gt;, plus the join, plus the tests, and make any headline counts ("legal in N countries") &lt;em&gt;derived from the data at build time&lt;/em&gt;, never hand-written. Hardcoded counts are just a fifth copy.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Don't let the auditor grade its own answer key
&lt;/h2&gt;

&lt;p&gt;One more thing, because it's the part people skip: during verification, the agent's own recollection of the law was flat-out wrong at least once. It "remembered" that a certain country had enacted a medical bill. Web search showed the bill was introduced and never passed — the database's boring "illegal" was right, and the confident model memory was the error.&lt;/p&gt;

&lt;p&gt;Which means the audit loop has to be: model proposes, primary sources dispose. An LLM auditing data against its own training memory isn't an audit. It's the same failure mode as the template default in Way #2 — confident filler — wearing a reviewer's badge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd carry to any dataset
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audit what renders, not what's stored.&lt;/strong&gt; Simulate the merge. The user never sees your database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defaults are policy.&lt;/strong&gt; Unknown must round to the strictest value, never the most plausible one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every copy of a fact is a liability.&lt;/strong&gt; Count your copies. You have more than you think, and the search index is always one of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A fix without a render test isn't a fix.&lt;/strong&gt; The Caribbean corrections were "done" for months. Done means displayed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify against sources, not model memory&lt;/strong&gt; — especially when the model is the one doing the verifying.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total cost of the whole thing — audit, verification, code fixes, database corrections, tests, deploy — was one long session with the agent doing the dumps, diffs, and searches. The only genuinely human part was the judgment calls: what "medical" should even &lt;em&gt;mean&lt;/em&gt; to a traveler. Everything else was exactly the kind of work agents should be doing: patient, exhaustive, and immune to the plausibility that let these errors sit unnoticed for months.&lt;/p&gt;

</description>
      <category>database</category>
      <category>debugging</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>One Page Ranked #6 and #20 at the Same Time. The Average Said #13.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Wed, 01 Jul 2026 14:22:38 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/one-page-ranked-6-and-20-at-the-same-time-the-average-said-13-2d74</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/one-page-ranked-6-and-20-at-the-same-time-the-average-said-13-2d74</guid>
      <description>&lt;p&gt;A page I run sat at average position 13 in Search Console. That's the worst place a page can be — not page one, not far enough down to ignore. The kind of number you glance at, sigh, and scroll past.&lt;/p&gt;

&lt;p&gt;I almost did. Then one question stopped me — the one an average can never answer: was that number describing &lt;em&gt;one&lt;/em&gt; ranking, or two? I pulled the query-level breakdown to find out, and the "13" fell apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The average was two numbers wearing a trenchcoat
&lt;/h2&gt;

&lt;p&gt;The page is built for one category of listing — call it Category A. For the A queries, it ranks &lt;strong&gt;5 to 9&lt;/strong&gt;. Top of page one, healthy click-through, doing its job.&lt;/p&gt;

&lt;p&gt;But the same page also showed up for a second, adjacent category — Category B — at &lt;strong&gt;position 20&lt;/strong&gt;. Page two. A handful of clicks on hundreds of impressions.&lt;/p&gt;

&lt;p&gt;Lay the two query populations side by side and the problem is obvious at a glance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Category A          Category B
─────────           ─────────
pos  5              pos 20
pos  6              pos 19
pos  7              pos 22
pos  6              pos 21
pos  8              pos 20
─────────           ─────────
avg ≈ 6.4           avg ≈ 20.4

Page average reported: ≈ 13
  a rank it never held
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Average those two clusters together, weighted by impressions, and you get 13. A number that describes neither reality. The page wasn't "mediocre everywhere." It was excellent at one thing and irrelevant at another, and the mean smeared them into a single grey lie.&lt;/p&gt;

&lt;p&gt;This is the trap with average position: it's an &lt;em&gt;average&lt;/em&gt;. A page that ranks #3 for everything and a page that ranks #6 and #20 for two different intents can report the identical number. One is finished; the other has a structural problem the headline metric will never show you. You only see it when you split by query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Category-A page was chasing Category-B queries
&lt;/h2&gt;

&lt;p&gt;The next question was: why is an A page surfacing for B at all?&lt;/p&gt;

&lt;p&gt;The answer was in its own title tag. The page title named &lt;em&gt;both&lt;/em&gt; categories — "A &amp;amp; B." I'd written it that way months earlier thinking it would catch more traffic. Instead it did the opposite of what I wanted.&lt;/p&gt;

&lt;p&gt;A title tag is a magnet for intent. Tell Google your page is about B, and Google will test it against B searches — even when the page body is really about A, even when you have a &lt;em&gt;different, dedicated&lt;/em&gt; page for B. So the engine dutifully ranked my A page at position 20 for B queries, pulling in impressions it could never convert and dragging the page's average into the swamp.&lt;/p&gt;

&lt;p&gt;Advertising a category you can't win for isn't free reach. It's a tax.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually stung: I was competing with myself
&lt;/h2&gt;

&lt;p&gt;When I pulled every page of mine that ranked for those Category-B queries, three URLs came back. The A page at ~20. A general directory page at ~23. And the &lt;em&gt;actual&lt;/em&gt; B-specific article at ~15.&lt;/p&gt;

&lt;p&gt;Three of my own pages, splitting authority for one query cluster, and &lt;strong&gt;none of them winning.&lt;/strong&gt; Classic self-cannibalization. Google had three candidates from the same site and couldn't decide which deserved the spot, so it ranked all three poorly and gave the position to someone else entirely.&lt;/p&gt;

&lt;p&gt;I'd manufactured my own competition. The dedicated B article — the page that &lt;em&gt;should&lt;/em&gt; own that intent — was being undercut by two other pages of mine that mentioned B in passing.&lt;/p&gt;

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

&lt;p&gt;The instinct with SEO is to add: more words, more keywords, more content, more links. This fix was the reverse. I edited the A page's title and meta to &lt;strong&gt;remove&lt;/strong&gt; every mention of Category B. No new content. No new links. Just deleting the words that were inviting the wrong queries.&lt;/p&gt;

&lt;p&gt;The goal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The A page stops matching B searches, stops ranking #20 for them, and concentrates its signals on the cluster it already wins (the 5–9 range).&lt;/li&gt;
&lt;li&gt;Category B gets handed cleanly to the one page meant to own it, instead of being split three ways.&lt;/li&gt;
&lt;li&gt;The "average position 13" should resolve upward — not because anything ranked &lt;em&gt;better&lt;/em&gt;, but because I stopped averaging in the queries the page was never going to win.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I pushed the change and submitted the URL for recrawl so the engine sees the new title sooner rather than waiting on its own schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm taking from this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Never trust an averaged metric to tell you what to do.&lt;/strong&gt; Average position, average CTR, average anything — they hide bimodal distributions, and bimodal is exactly where the interesting problems live. The page-level number told me "meh." The query-level split told me "two different problems wearing one URL." The whole diagnosis lived one dimension down.&lt;/p&gt;

&lt;p&gt;This is also why I have the query breakdown wired to pull on demand rather than reading dashboards by hand. The signal that mattered here wasn't in any summary view — it was in the shape of the distribution, and you only see the shape when something queries the raw rows and looks for the split. The page average will tell you a page is sick. It will almost never tell you that the page is two pages, one of them healthy, fighting over a title tag.&lt;/p&gt;

&lt;p&gt;I'll know in a couple of weeks whether the subtraction worked. The recheck is already scheduled to pull the same queries and compare. But the lesson holds regardless of the numbers: when a page looks evenly bad, check whether it's actually unevenly good.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>monitoring</category>
      <category>agents</category>
      <category>search</category>
    </item>
    <item>
      <title>I Ignored Bing for Months. It Ranked My Pages Backwards from Google.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Thu, 25 Jun 2026 02:42:54 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/i-ignored-bing-for-months-it-ranked-my-pages-backwards-from-google-2gbi</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/i-ignored-bing-for-months-it-ranked-my-pages-backwards-from-google-2gbi</guid>
      <description>&lt;p&gt;I'd spent months optimizing for one search engine. Not on purpose — Google is just where I looked. Search Console was wired into my morning reports, the numbers showed up in Telegram every day, and that was "search performance." Bing Webmaster Tools existed in a browser tab I never opened.&lt;/p&gt;

&lt;p&gt;When I finally opened it, the data didn't agree with Google. It actively contradicted it.&lt;/p&gt;

&lt;p&gt;On the site, two pages cover the same subject. One is a dated long-form article — the "everything about this topic, updated for 2026" piece. The other is an interactive map that renders the same underlying data geographically: pick a place, see its status. They target the same cluster of queries.&lt;/p&gt;

&lt;p&gt;On Google, the article wins decisively — roughly five times the impressions of the map, and a better average position. I'd internalized that as settled: Google had picked the article as the answer, end of story.&lt;/p&gt;

&lt;p&gt;On Bing, the map wins. The article is barely present. The map page alone accounts for &lt;strong&gt;56% of every impression the site gets on Bing.&lt;/strong&gt; The article that dominates Google sits near the bottom of the list.&lt;/p&gt;

&lt;p&gt;Same two pages. Opposite order. Two engines looking at identical content and disagreeing about which one deserves to rank.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Google&lt;/th&gt;
&lt;th&gt;Bing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Long-form article&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Wins&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Loses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interactive map&lt;/td&gt;
&lt;td&gt;Loses&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Wins&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why an engine "picks" a page
&lt;/h2&gt;

&lt;p&gt;This isn't randomness, and it isn't a quirk of one crawl. The two engines reward different things for the same informational query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google rewards the article.&lt;/strong&gt; Its helpful-content systems lean hard toward substantive, dated, written pages for informational intent. A long article with depth, a clear publish date, and a "2026" freshness signal reads to Google as &lt;em&gt;the authoritative answer&lt;/em&gt; to "tell me about this topic." The map page — mostly an interactive widget with thin surrounding text — reads as a tool, not an answer. Google buries it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bing rewards the tool.&lt;/strong&gt; Bing is more literal. When the query itself implies a lookup — anything phrased as a &lt;em&gt;map&lt;/em&gt; or a &lt;em&gt;by-location&lt;/em&gt; question — Bing matches the page whose entire purpose is that lookup. The map page's title, structure, and intent line up with the query word-for-word, so Bing puts it first. The article, to Bing, is just a wall of text adjacent to the thing the user actually asked for.&lt;/p&gt;

&lt;p&gt;Neither is wrong. They're optimizing for different definitions of "best result," and my two pages happened to be perfect representatives of each definition. So each engine crowned a different king.&lt;/p&gt;

&lt;p&gt;The inversion showed up at the sub-page level too, even more starkly: one regional reference page ranks on the &lt;strong&gt;first page of Bing&lt;/strong&gt; and sits at &lt;strong&gt;position 69 on Google&lt;/strong&gt; — effectively invisible. Same page. The gap there isn't content; it's authority. Those head terms on Google are owned by encyclopedias, government sites, and AI summaries, and a young page doesn't crack them. Bing's bar is lower, so the same page surfaces. Watching only Google, I'd have called that page a failure. It isn't — it's just winning on the engine I never checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  The zero-click trap
&lt;/h2&gt;

&lt;p&gt;Here's the part that nearly fooled me twice.&lt;/p&gt;

&lt;p&gt;The map page's top Bing queries — the high-volume, head-of-cluster ones — rank around positions 6 to 9 and convert &lt;strong&gt;almost no clicks.&lt;/strong&gt; My first read was "impressions without clicks, so this is worthless ranking."&lt;/p&gt;

&lt;p&gt;That might be the wrong conclusion. One likely explanation — and I want to be careful to call it a hypothesis, not a proven cause — is that some of those impressions are being &lt;strong&gt;answered inline by an AI assistant&lt;/strong&gt;: the model pulls the page into its grounding set, writes the answer, and the user never clicks because they already got what they came for. I've &lt;a href="https://tedagentic.com/posts/ai-citation-aeo-geo-perplexity" rel="noopener noreferrer"&gt;written before about why AI citation is becoming the metric that replaces the click&lt;/a&gt;, and a high-impression, zero-click, mid-position query is exactly the shape that pattern leaves behind.&lt;/p&gt;

&lt;p&gt;Here's the honest limit, though: &lt;strong&gt;I can't confirm it from the data I have.&lt;/strong&gt; The search engine's webmaster API reports impressions, clicks, and positions — it does &lt;em&gt;not&lt;/em&gt; expose whether an AI surface cited the page. So "these zero-click impressions are AI citations" is an inference from the shape of the numbers, not something I measured. It's plausible, it fits, and it's the explanation I'd bet on — but it's a bet, and treating it as confirmed would be the exact &lt;a href="https://tedagentic.com/posts/ai-confidence-gap-production-sites" rel="noopener noreferrer"&gt;confidence gap&lt;/a&gt; I keep warning about. What I &lt;em&gt;can&lt;/em&gt; say for certain is narrower and still useful: zero clicks at position 6–9 does not mean zero value, and reading it as failure would be a mistake.&lt;/p&gt;

&lt;p&gt;So "zero clicks" on those queries isn't dead weight. It's the citation surface. The clicks I &lt;em&gt;do&lt;/em&gt; get come from longer-tail queries lower in the cluster, ranking higher, where no AI summary intercepts them. Two completely different value mechanisms inside the same page, and you can only tell them apart if you stop treating clicks as the only outcome that counts. (If the GEO/AEO/citation vocabulary is new, I &lt;a href="https://tedagentic.com/posts/geo-generative-engine-optimization" rel="noopener noreferrer"&gt;untangled the three terms here&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What watching one engine hid
&lt;/h2&gt;

&lt;p&gt;I would not have seen any of this from Google alone. Not the inversion, not the page that's invisible on one engine and page-one on the other, not the citation pattern. Google's data is internally consistent and tells a clean story — &lt;em&gt;the article is the answer&lt;/em&gt; — and that story is true on Google and false everywhere else.&lt;/p&gt;

&lt;p&gt;That's the part that actually unsettled me, and it's bigger than Bing. For years my working definition was &lt;em&gt;Google = search&lt;/em&gt;. One dashboard, one set of numbers, one verdict on whether a page worked. But search has quietly become plural — Google, Bing, AI Overviews, Perplexity, ChatGPT, Gemini — and they don't agree, because they don't rank the same way or even reward the same kind of page. My mental model wasn't wrong so much as &lt;em&gt;incomplete&lt;/em&gt;: I was reading one instrument and calling it the weather. The inversion was just the first thing that didn't fit, because it was the first time I looked at a second instrument.&lt;/p&gt;

&lt;p&gt;The fix was boring and is the actual point: pipe the second engine into the same place as the first. Bing exposes a Webmaster API with the same kind of search-performance data Google's does — totals, top pages, top queries, positions — behind a single API key you generate in the tool's settings. I &lt;a href="https://tedagentic.com/posts/automating-seo-monitoring-with-ai-agents" rel="noopener noreferrer"&gt;wired Google Search Console into a daily Telegram briefing months ago&lt;/a&gt;; adding Bing was the same shape of work pointed at a different endpoint. Now both engines land in the same thread, weekly, side by side — a short "this week vs last week" and a six-month trend so I can watch the curve. (That curve, for the record, has compounded every month from a near-zero start — visibility I had no idea was accruing because I wasn't looking.)&lt;/p&gt;

&lt;p&gt;One gotcha worth saving someone an hour: the related &lt;strong&gt;IndexNow&lt;/strong&gt; endpoint — the protocol that lets you ping Bing and others the instant a URL changes — returned &lt;code&gt;403 Forbidden&lt;/code&gt; for every request until I added a &lt;code&gt;User-Agent&lt;/code&gt; header. &lt;code&gt;curl&lt;/code&gt; and the browser don't care; the API does. No error message explains it. If your IndexNow submissions are silently rejected, send a User-Agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;"Rank" isn't one number. The same page can be a winner and a loser at the same moment depending on who's asking, and the two biggest engines can disagree completely about content they're both looking at.&lt;/p&gt;

&lt;p&gt;The practical move once you see the split is &lt;em&gt;not&lt;/em&gt; to force the pages to fight — I'm not stripping the article of the terms it wins on Google to help the map, or vice versa. They've each won an engine. The right move is to let them, cross-link them so they reinforce instead of compete, and keep both engines on the dashboard so the next divergence isn't invisible for months.&lt;/p&gt;

&lt;p&gt;But the durable lesson isn't about Bing at all. It's that treating one engine as the whole of search means your picture is incomplete by definition — and you won't know what you're missing until you instrument the engines you've been ignoring. Mine had a story in it the whole time. I just wasn't looking.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://tedagentic.com/posts/bing-google-ranking-inversion" rel="noopener noreferrer"&gt;tedagentic.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>bing</category>
      <category>google</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Chrome Showed the Data. Firefox Showed Nothing. The API Was Being Blocked.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Wed, 17 Jun 2026 15:26:31 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/chrome-showed-the-data-firefox-showed-nothing-the-api-was-being-blocked-4mpn</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/chrome-showed-the-data-firefox-showed-nothing-the-api-was-being-blocked-4mpn</guid>
      <description>&lt;p&gt;I was looking at the same page in two browsers side by side. In Chrome: a full list of results, images, working buttons. In Firefox: a different, smaller set of placeholder cards with no images and no links — and a banner that read "0 results found."&lt;/p&gt;

&lt;p&gt;Same URL. Same deployed build. No "works on my machine" excuse, because it &lt;em&gt;wasn't&lt;/em&gt; working on the other machine — it was the same machine, two windows.&lt;/p&gt;

&lt;p&gt;That contradiction is the whole story, and the cause was a class of bug that is almost impossible to catch in development: &lt;strong&gt;the browser was blocking my own API call, and I'd been developing in the one browser that didn't.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How the page is built
&lt;/h2&gt;

&lt;p&gt;The page renders a list of results that come from a database. The pattern is the common one for a single-page app: the page ships, then a bit of JavaScript runs and fetches the data from a hosted Postgres/API service (Supabase, in this case) at its own domain — something like &lt;code&gt;xxxx.supabase.co&lt;/code&gt;. The results come back, React renders the cards.&lt;/p&gt;

&lt;p&gt;If that fetch returns nothing, the component falls back to a hardcoded placeholder list so the page isn't blank. That fallback is what Firefox was showing. So the real question was narrow: &lt;strong&gt;why did the fetch return nothing in Firefox but everything in Chrome?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnosis
&lt;/h2&gt;

&lt;p&gt;The server was fine. I could hit the API directly from the command line and get the full result set, HTTP 200. The database, the credentials, the query — all correct. Chrome proved it too: it ran the exact same JavaScript against the exact same endpoint and got the data.&lt;/p&gt;

&lt;p&gt;The difference was the browser, and specifically what the browser &lt;em&gt;allowed&lt;/em&gt;. Firefox ships with Enhanced Tracking Protection, and a lot of people add uBlock Origin or Privacy Badger on top. Those tools block requests to domains they classify as third-party trackers. The relevant move is in the browser's network panel: the request to &lt;code&gt;xxxx.supabase.co&lt;/code&gt; wasn't failing with an error from the server — it was being &lt;strong&gt;blocked before it ever left&lt;/strong&gt;, by the privacy layer.&lt;/p&gt;

&lt;p&gt;From my code's point of view, the fetch just throws. &lt;code&gt;data&lt;/code&gt; comes back empty. The component does what I told it to do with no data: show the fallback. No crash, no red console error a casual glance would notice. The page "works." It just works with nothing in it.&lt;/p&gt;

&lt;p&gt;The whole bug fits in two columns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   Chrome                      Firefox + tracking protection

   Page                        Page
    ↓                           ↓
   Fetch supabase.co           Fetch supabase.co
    ↓                           ↓
   Data arrives                Blocked
    ↓                           ↓
   Render results              Fallback cards
                                ↓
                               0 results
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same code reaching the same fork, and the third step decides everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is so easy to ship
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable part. I build in Chrome. Most developers build in Chrome. Chrome, with no aggressive privacy extensions, happily makes the cross-origin call, so the page is perfect every single time I look at it. The failure only appears in an environment I never use during development.&lt;/p&gt;

&lt;p&gt;And it gets worse, because the &lt;em&gt;measurement&lt;/em&gt; is blocked too. My click tracking — the thing that tells me whether people are using the page — went through the same API domain. So the users who couldn't see the content also couldn't be counted &lt;em&gt;not&lt;/em&gt; using it. The bug hides in the browser I don't test, and it erases its own evidence in the analytics. It's invisible twice.&lt;/p&gt;

&lt;p&gt;The only reason I found it is that someone opened the page in Firefox and said "this looks broken."&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix one: stop the fallback being a dead end
&lt;/h2&gt;

&lt;p&gt;The first thing I did was cheap and defensive. The placeholder fallback had no links — a blocked user landed on dead cards and had nowhere to go. So I made every fallback card and every "quick pick" link to the real directory page. Now, whatever blocks the fetch, the visitor still has a working path forward.&lt;/p&gt;

&lt;p&gt;This is worth doing regardless, but be honest about what it is: a &lt;strong&gt;safety net&lt;/strong&gt;, not a fix. The user is still being denied the real content. They're just no longer stranded. To actually fix it, the call has to stop being blockable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix two: make the call first-party
&lt;/h2&gt;

&lt;p&gt;It's worth being precise about what I'm fixing here, because it's easy to mislabel. This isn't a Supabase problem — Supabase did everything right. It's a &lt;strong&gt;browser trust-boundary problem&lt;/strong&gt;. The browser draws a line between "the site I'm on" and "some other domain," and privacy filters police that line. My data was on the wrong side of it.&lt;/p&gt;

&lt;p&gt;So the fix isn't to change the database. It's to move the request to the trusted side of the line — to change what the browser &lt;em&gt;sees&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   Before                       After

   mysite.com                   mysite.com
    ↓                            ↓
   xxxx.supabase.co             mysite.com/sb-api
   (third-party — policed)      (first-party — trusted)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same data, same backend. The only thing that changed is whose domain the browser thinks it's talking to. You do that with a reverse proxy: route the API through your own domain so the browser only ever sees a same-origin request to the site it's already on. On Vercel that's a rewrite in &lt;code&gt;vercel.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/sb-api/:path*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://xxxx.supabase.co/:path*"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then point the client at the first-party path instead of the vendor's domain:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SUPABASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PROD&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/sb-api`&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RAW_SUPABASE_URL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// dev + build-time stay on the direct URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the browser makes a request to &lt;code&gt;mysite.com/sb-api/...&lt;/code&gt;, which Vercel quietly forwards to the real API. To Firefox's tracking protection it's a call to the site you're already visiting — first-party, not a tracker, not blocked. As a bonus, the browser no longer has to negotiate cross-origin access at all.&lt;/p&gt;

&lt;p&gt;I verified it the same way I found the bug — by hitting the new first-party URL and watching the real data come back through it — then opened the page in Firefox and got the full list, images and all, identical to Chrome.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas, because there are always gotchas
&lt;/h2&gt;

&lt;p&gt;A reverse proxy in front of your whole data layer is a real change. Three things to check before you ship it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Websockets don't ride through a simple rewrite.&lt;/strong&gt; If you use the service's realtime/subscription features, a path rewrite won't proxy the socket cleanly. I got to skip this because the app uses none — but check first, or you'll trade a Firefox bug for a realtime outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin your auth storage key.&lt;/strong&gt; Client libraries often derive the key they store the session under from the API URL. Change the URL and the key changes, and everyone silently gets logged out. Set the storage key explicitly to the original value so swapping the URL is invisible to existing sessions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep server-side and build-time calls on the direct URL.&lt;/strong&gt; Anything running outside the browser — a build step, prerendering, server code — has no ad blocker and no same-origin to honor. Sending it through the proxy is pointless and can be circular. Gate the rewrite to the browser only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The crawler is just another blocked client
&lt;/h2&gt;

&lt;p&gt;There's a tidy way to think about all of this. A browser with tracking protection is a client that won't run part of your page. A search crawler is &lt;em&gt;also&lt;/em&gt; a client that won't run part of your page — it may not execute your fetch at all. Both end up looking at the empty shell.&lt;/p&gt;

&lt;p&gt;So the durable answer isn't only the proxy; it's not making the page's existence depend on a fetch that some clients will never complete. I also prerender the real results into the page's static HTML at build time, so the meaningful content is in the document before any JavaScript — for crawlers, for blocked browsers, for anyone whose script never runs. The proxy fixes the live experience; prerendering makes sure there's something real there even when nothing runs at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A page that's perfect in Chrome can be empty in Firefox.&lt;/strong&gt; If your data comes from a client-side call to a third-party domain, privacy filters will block it for a real slice of users, and you'll never see it in the browser you develop in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent failure is the dangerous kind.&lt;/strong&gt; A blocked fetch doesn't crash — it just renders your empty state. Make the empty state recoverable, but don't mistake that for a fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The blocked users are uncounted users.&lt;/strong&gt; If your analytics and your content share an API domain, the people who can't see the page also don't show up as not seeing it. Test in Firefox with tracking protection on, and with an ad blocker — that's your blind spot, on purpose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First-party proxy beats third-party fetch.&lt;/strong&gt; Route vendor APIs through your own domain so the browser sees same-origin. Mind websockets, pin the auth key, keep build/server calls direct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat crawlers and blocked browsers as the same problem.&lt;/strong&gt; Anything that won't run your JavaScript needs the real content in the HTML already.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Underneath the specifics, this was the same shape as most of the worst bugs I find:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Human validation passed
        ↓
System validation failed
        ↓
Find the hidden assumption
        ↓
Fix the assumption, not the symptom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hidden assumption here was four words long — &lt;em&gt;"if it works in Chrome"&lt;/em&gt; — and the real version was &lt;em&gt;"it works in the browser I happened to develop in."&lt;/em&gt; The fix wasn't really a proxy. The proxy was just how I retired the assumption.&lt;/p&gt;

&lt;p&gt;The page had been broken for months for an entire category of visitors. It looked flawless the whole time — in the one browser I happened to use.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>debugging</category>
      <category>frontend</category>
    </item>
    <item>
      <title>My Redirects Worked in the Browser. Googlebot Saw Soft 404s.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Sun, 14 Jun 2026 11:06:53 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/my-redirects-worked-in-the-browser-googlebot-saw-soft-404s-1pl4</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/my-redirects-worked-in-the-browser-googlebot-saw-soft-404s-1pl4</guid>
      <description>&lt;p&gt;Google Search Console flagged six URLs on a client site as &lt;strong&gt;Soft 404&lt;/strong&gt;. Every one of them returned a clean &lt;code&gt;200 OK&lt;/code&gt; when I curled it. So how does a page that loads fine get reported as "not found"?&lt;/p&gt;

&lt;p&gt;That contradiction is the whole story, and the answer turned out to be a category of bug I'd been shipping without realizing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Soft 404" actually means
&lt;/h2&gt;

&lt;p&gt;A hard 404 is honest: the server returns a 404 status, Google drops the URL, everyone moves on. A &lt;strong&gt;soft&lt;/strong&gt; 404 is when the server returns &lt;code&gt;200 OK&lt;/code&gt; but the &lt;em&gt;content&lt;/em&gt; looks like an error or an empty page to Google. The status line says "here's your page," the body says "there's nothing here." Google trusts the body.&lt;/p&gt;

&lt;p&gt;On a single-page app, there's a very common way to produce exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;The site is a React/Vite SPA, deployed on Vercel, with a prerender layer that injects real HTML for SEO so the crawler doesn't have to run JavaScript. Routes that are prerendered get proper content. Routes that &lt;em&gt;aren't&lt;/em&gt; fall through to Vercel's catch-all, which serves the app shell — effectively the homepage HTML — at whatever URL was requested.&lt;/p&gt;

&lt;p&gt;Hold onto that last sentence. It's the trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fingerprint
&lt;/h2&gt;

&lt;p&gt;All six soft-404s shared a pattern: they were &lt;strong&gt;old, renamed blog slugs&lt;/strong&gt;. A few were year-suffixed posts I'd renamed (think &lt;code&gt;/blog/some-guide-2025&lt;/code&gt; → &lt;code&gt;/blog/some-guide-2026&lt;/code&gt;). Two were older duplicates of a post that now lives at a cleaner URL. One was the same path on the &lt;code&gt;www&lt;/code&gt; host instead of the apex.&lt;/p&gt;

&lt;p&gt;Old URLs that should redirect somewhere. And in the codebase, three of them &lt;em&gt;did&lt;/em&gt; have redirects. That's what made this confusing — I had redirects, and Google was still calling the pages broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: the redirects were client-side
&lt;/h2&gt;

&lt;p&gt;The three "redirected" routes used the SPA router's redirect component — the React-Router &lt;code&gt;&amp;lt;Navigate&amp;gt;&lt;/code&gt; element. In a browser, it works perfectly: the app boots, the router matches the old path, and the user is bounced to the new URL before they notice.&lt;/p&gt;

&lt;p&gt;But look at the order of operations from &lt;strong&gt;Googlebot's&lt;/strong&gt; point of view:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Request &lt;code&gt;/blog/some-guide-2025&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The route isn't prerendered, so Vercel serves the app shell — homepage-ish HTML, status &lt;code&gt;200&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Then&lt;/em&gt; the JavaScript would run and redirect — but the crawler has already been handed a 200 with the wrong content.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The redirect lives inside the JavaScript. The crawler's verdict is formed before the JavaScript runs. So Google sees a 200 response whose body is the homepage, served at a URL that's supposed to be a specific blog post — a page that resolves to nothing meaningful. &lt;strong&gt;Soft 404.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The other two had no redirect at all and went straight to the shell fallback — same outcome by a more direct route.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A client-side redirect is invisible to a crawler that judges the first response.&lt;/strong&gt; If you want Google to treat a URL as moved, the move has to happen in the response itself, before any JavaScript: a real server-side 3xx.&lt;/p&gt;

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

&lt;p&gt;The fix is to do the redirect at the edge, not in the app. On Vercel that's a &lt;code&gt;redirects&lt;/code&gt; entry in &lt;code&gt;vercel.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/blog/old-slug"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/blog/new-slug"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permanent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;"permanent": true&lt;/code&gt; emits a 308 (Vercel's permanent redirect), which Google treats the same as a 301. Now the &lt;em&gt;first&lt;/em&gt; response Googlebot gets is "this moved, permanently, here" — no shell, no JavaScript, no ambiguity. The &lt;code&gt;www&lt;/code&gt; variant was already handled by an apex-redirect rule, so once the apex path resolved correctly, the &lt;code&gt;www&lt;/code&gt; one chained into it.&lt;/p&gt;

&lt;p&gt;I added server-side redirects for the two that had none, pointed both old duplicates at the canonical post, and the client-side &lt;code&gt;&amp;lt;Navigate&amp;gt;&lt;/code&gt; routes became harmless fallbacks behind the real ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  The twist: most of them were already fixed
&lt;/h2&gt;

&lt;p&gt;Here's the part that saved me a pile of unnecessary work — and that I almost skipped.&lt;/p&gt;

&lt;p&gt;Before "fixing" the three that already had server-side redirects, I checked &lt;em&gt;when Google last crawled them&lt;/em&gt;. The URL Inspection API returns &lt;code&gt;lastCrawlTime&lt;/code&gt;. Every one of those dates was &lt;strong&gt;weeks before&lt;/strong&gt; the server-side redirects had shipped. The pages weren't broken anymore. Google's report was a snapshot from the last time it looked, and it simply hadn't looked again.&lt;/p&gt;

&lt;p&gt;GSC statuses are not live. They're the result of the most recent crawl, which can be a month stale on a low-traffic site. Before you re-fix something the report calls broken, check &lt;code&gt;lastCrawlTime&lt;/code&gt; — you may be debugging a problem that no longer exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Submission is not a fix
&lt;/h2&gt;

&lt;p&gt;The instinct, once the redirects were in, was to "submit the pages to Google." But you don't submit a redirect &lt;em&gt;source&lt;/em&gt; — it's not content, it's a signpost. The Indexing API is for telling Google a real page changed. For redirected URLs, the correct lever is &lt;strong&gt;GSC → "Validate Fix"&lt;/strong&gt; on the soft-404 report, which re-queues the crawl. The only thing worth submitting is the redirect &lt;em&gt;targets&lt;/em&gt; — the live pages — so Google freshens those.&lt;/p&gt;

&lt;p&gt;Resubmitting a URL Google already crawled doesn't change Google's mind. It just asks the same question again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other bucket: "crawled, not indexed"
&lt;/h2&gt;

&lt;p&gt;Separately, a larger batch of pages on the same site had been sitting in &lt;strong&gt;"Crawled – currently not indexed"&lt;/strong&gt; — and around two dozen of them cleared into the index over the same period. It's tempting to lump that together with the soft-404 fix, but it's a different problem with a different cause.&lt;/p&gt;

&lt;p&gt;Soft 404 is a &lt;em&gt;technical&lt;/em&gt; verdict: wrong response, fix the response. "Crawled, not indexed" is a &lt;em&gt;quality&lt;/em&gt; verdict: Google fetched a real page and chose not to index it. You don't move that with redirects or resubmission — you move it with better content (the prerender layer putting real HTML in front of the crawler) and internal links that give the page a reason to matter. Those recoveries were the delayed payoff of content and linking work from weeks earlier, not anything I did that day.&lt;/p&gt;

&lt;p&gt;Keeping the two buckets separate matters, because the fixes don't transfer. A 308 will never rescue a thin page, and a content rewrite will never fix a client-side redirect.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;200&lt;/code&gt; status doesn't mean Google sees a real page. Soft 404 is the body contradicting the status line.&lt;/li&gt;
&lt;li&gt;On an SPA, any route that isn't prerendered falls through to the app shell — a 200 full of the wrong content. That's a soft-404 factory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client-side redirects don't exist as far as a crawler is concerned.&lt;/strong&gt; Anything Google should treat as moved needs a server-side 3xx, before the JavaScript.&lt;/li&gt;
&lt;li&gt;GSC reports are stale snapshots. Check &lt;code&gt;lastCrawlTime&lt;/code&gt; before re-fixing.&lt;/li&gt;
&lt;li&gt;Submission re-asks the question; it doesn't change the answer. Use "Validate Fix" for redirects; submit only live targets.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The redirects had been working the entire time — in the one place that couldn't see them.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>react</category>
      <category>vercel</category>
    </item>
    <item>
      <title>Migrating Off OpenClaw Without Downtime — and the Offset That Made Hermes Look Dead</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Sun, 14 Jun 2026 10:19:10 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/migrating-off-openclaw-without-downtime-and-the-offset-that-made-hermes-look-dead-2gic</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/migrating-off-openclaw-without-downtime-and-the-offset-that-made-hermes-look-dead-2gic</guid>
      <description>&lt;p&gt;A while back I wrote a &lt;a href="https://tedagentic.com/posts/openclaw-vs-hermes-agent" rel="noopener noreferrer"&gt;comparison of OpenClaw and Hermes&lt;/a&gt; — two open-source, self-hosted AI agents I run on the same bare-metal box, both wired to Telegram. The verdict was that they're complementary: OpenClaw as the dependable gateway for scheduled delivery, Hermes as the agent that builds context over time. Run both, I said. Don't choose.&lt;/p&gt;

&lt;p&gt;Then I actually lived with both for a few more weeks, and the verdict started to move.&lt;/p&gt;

&lt;p&gt;OpenClaw is &lt;strong&gt;gateway-first&lt;/strong&gt;: a messaging hub that runs plugins, models, and cron delivery on a schedule you define. Hermes is &lt;strong&gt;agent-first&lt;/strong&gt;: the messaging is just how you reach it, and the point is an agent that accumulates knowledge and needs less steering each time. On paper that made OpenClaw the better runtime for my scheduled jobs.&lt;/p&gt;

&lt;p&gt;The migration started for a plainer reason than "one is better": I'd come to trust Hermes more with anything long-running. When I handed my OpenClaw setup a task with real weight, it would acknowledge it and then go quiet — the agent loop hitting a timeout and dropping the work before it finished. That isn't a verdict on the project; it's a mismatch between how I had it configured and the load I was putting on it. But trust is trust, and mine had moved.&lt;/p&gt;

&lt;p&gt;Hermes, on the same machine, on a &lt;em&gt;free&lt;/em&gt; OpenRouter model, just finishes. It holds the task, retries, and delivers — because the persistence is in the harness, not the model. That's the whole thing: the model matters less than whether the system around it gives up.&lt;/p&gt;

&lt;p&gt;So I decided to migrate. But not the way you'd think.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule: don't turn anything off
&lt;/h2&gt;

&lt;p&gt;The constraint I set for myself was that OpenClaw keeps running, untouched, the entire time. No big-bang switch. Two reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's my fallback.&lt;/strong&gt; If the new path has a blind spot I haven't seen, I don't want to discover it by losing notifications.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I want to compare.&lt;/strong&gt; Running both in parallel is the only way to see &lt;em&gt;where&lt;/em&gt; each one lags before I commit. You don't get that from a one-way cutover.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the plan became: deliver everything through Hermes &lt;em&gt;in parallel&lt;/em&gt; with OpenClaw for a couple of weeks, watch them side by side, then retire OpenClaw's delivery one job at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I underestimated: there wasn't one delivery path. There were four.
&lt;/h2&gt;

&lt;p&gt;My notification stack is a pile of cron jobs — site monitors, system-health checks, morning summaries, scheduled reports. The important thing about them is that &lt;strong&gt;the agent never touches them.&lt;/strong&gt; They're pure notifiers: each one computes a result and pushes it straight to Telegram. The agent only gets involved when I actually ask it something.&lt;/p&gt;

&lt;p&gt;I assumed they all delivered the same way. They didn't. When I went looking, there were &lt;strong&gt;four&lt;/strong&gt; different mechanisms across ~39 delivery points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scripts shelling out to the OpenClaw CLI&lt;/li&gt;
&lt;li&gt;Node monitors hitting the Telegram Bot API directly&lt;/li&gt;
&lt;li&gt;A shared bash helper used by a few jobs&lt;/li&gt;
&lt;li&gt;One Python script posting straight to the API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There was no single switch to flip. Which is exactly why a clean cutover would have been a bad idea.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mirror pattern
&lt;/h2&gt;

&lt;p&gt;Instead of rerouting anything, I made the new delivery &lt;strong&gt;purely additive&lt;/strong&gt;. One control script — &lt;code&gt;hermes_mirror.sh&lt;/code&gt; — and every delivery point gets &lt;em&gt;one&lt;/em&gt; extra line that calls it in the background:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;bash jobs: &lt;code&gt;hermes_mirror.sh "$MSG" &amp;amp;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Python jobs: &lt;code&gt;subprocess.Popen([...])&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Node jobs: &lt;code&gt;child_process.spawn(...)&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The helper waits a moment, then sends the same message through Hermes. The original OpenClaw send is never modified, never wrapped, never moved. If the mirror errors or hangs, the existing delivery already happened — it cannot be affected. And because everything funnels through that one script, there's a single kill switch: set &lt;code&gt;ENABLED=0&lt;/code&gt; and &lt;em&gt;all&lt;/em&gt; mirroring stops at once. One offset value, one log, one place to reason about.&lt;/p&gt;

&lt;p&gt;That additive property is the whole safety story. I wasn't migrating the system. I was running a second, shadow system next to it and watching.&lt;/p&gt;

&lt;h2&gt;
  
  
  The offset that made it look broken
&lt;/h2&gt;

&lt;p&gt;Here's the part that actually taught me something.&lt;/p&gt;

&lt;p&gt;I didn't want the two copies of every notification landing in the same second — that's noisy and makes them hard to tell apart. So I set the mirror to wait &lt;strong&gt;three minutes&lt;/strong&gt; before sending. Comfortable separation. Seemed obviously correct.&lt;/p&gt;

&lt;p&gt;Then I ran the first real test. Fired a notification. It showed up on the old bot. The new one… nothing. I sat there watching an empty chat, fully convinced the Hermes path had failed.&lt;/p&gt;

&lt;p&gt;It hadn't. It was running exactly as designed — three minutes behind. The copy landed right on schedule, 180 seconds later, while I was already digging through logs looking for a bug that didn't exist.&lt;/p&gt;

&lt;p&gt;The lesson stuck: &lt;strong&gt;in a parallel migration, "late" reads as "broken."&lt;/strong&gt; A safety margin I added to keep things tidy became a false failure signal, because the only way I had to judge the new system was &lt;em&gt;did the message show up when I expected it.&lt;/em&gt; Three minutes of silence and your brain files it under "dead."&lt;/p&gt;

&lt;p&gt;But the delay itself wasn't the mistake. The mistake was adding a delay without adding any signal that the delay was &lt;em&gt;intentional&lt;/em&gt;. If a mirrored notification is meant to land three minutes later, something should say so — a log line, a status ping, anything that separates "received, waiting" from "never arrived." Without that, silence and failure are indistinguishable. &lt;strong&gt;A safety mechanism with no observability doesn't make a system safer; it makes it ambiguous&lt;/strong&gt; — and ambiguity, in a migration, is its own kind of failure.&lt;/p&gt;

&lt;p&gt;I cut the offset to &lt;strong&gt;20 seconds&lt;/strong&gt;. Long enough that the two copies never collide, short enough that the new one clearly arrives &lt;em&gt;with&lt;/em&gt; the original instead of looking like it got lost. Same mechanism, completely different read on whether it's working. (The better fix is still on the list: have the mirror log an explicit "received, holding 20s" line, so the wait is something you can &lt;em&gt;see&lt;/em&gt;, not infer.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The principles that fell out of it
&lt;/h2&gt;

&lt;p&gt;By the time the offset was sorted, the migration had quietly handed me its own rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never modify the primary delivery path first.&lt;/strong&gt; The thing that works keeps working, untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the new path alongside the old — purely additive.&lt;/strong&gt; It can fail without taking anything down with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep one global kill switch.&lt;/strong&gt; A single place to stop everything the moment it misbehaves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make delayed delivery &lt;em&gt;visibly&lt;/em&gt; delayed, not silently delayed.&lt;/strong&gt; A wait with no signal is indistinguishable from a failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove the old path only after watching a full execution cycle&lt;/strong&gt; — including the jobs that only fire weekly.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of them are clever. They're just what's left after you stop assuming the replacement works and start making it prove it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha in automating the edit
&lt;/h2&gt;

&lt;p&gt;Wiring one line into ~20 Python scripts, I wrote a small injector instead of editing each by hand. It dropped the mirror call in as the first line of each delivery function. Clean — except one script imported its dependency &lt;em&gt;inside&lt;/em&gt; the function, &lt;strong&gt;below&lt;/strong&gt; where my line now sat. So the injected call referenced a module that wasn't imported yet. Instant &lt;code&gt;NameError&lt;/code&gt;, only in that one file.&lt;/p&gt;

&lt;p&gt;Blind codemods across a heterogeneous pile of scripts will find the one file that doesn't match your mental model. Compile-check everything after, not just the ones you expect to fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I expected to be hard and wasn't
&lt;/h2&gt;

&lt;p&gt;I assumed I'd have to port OpenClaw's scheduled &lt;em&gt;agent&lt;/em&gt; jobs — the ones where it actually reasons, not just delivers. Turned out they were all already disabled, superseded long ago by plain deterministic scripts. The only thing OpenClaw was still genuinely doing for me on a schedule was acting as the mailman. The agentic work I thought I'd be migrating didn't exist anymore. The real dependency was just delivery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;Both agents now deliver the full stream in parallel, Hermes trailing each original by 20 seconds. OpenClaw is untouched and still my fallback. The plan is to watch a complete cycle — including the jobs that only fire weekly — and then start removing OpenClaw's send per-job, one at a time, only once I've seen each one deliver cleanly through Hermes.&lt;/p&gt;

&lt;p&gt;The migration isn't "done" — and that's the point. The mirror pattern turned it from a decision into an observation exercise. I never had to ask whether Hermes was ready; I could watch it prove it, one notification at a time, with the old system holding the floor the whole way.&lt;/p&gt;

&lt;p&gt;That's the part I'll carry to the next one. When you're replacing a live system you don't trust yet, the move isn't to cut over and hope. It's to run the replacement in the open, right next to the thing it's replacing, and let it earn the handoff — visibly, on a schedule you can watch, with nothing torn out until it has.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>GEO: What Generative Engine Optimization Actually Means for Your Content</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Thu, 11 Jun 2026 08:30:10 +0000</pubDate>
      <link>https://dev.to/henry_dan_81513dd35a2f540/geo-what-generative-engine-optimization-actually-means-for-your-content-4em1</link>
      <guid>https://dev.to/henry_dan_81513dd35a2f540/geo-what-generative-engine-optimization-actually-means-for-your-content-4em1</guid>
      <description>&lt;p&gt;Search didn't break. It restructured. The page that ranked #1 for an informational query now sits below an AI-generated summary that pulls from it — and the user never scrolls down.&lt;/p&gt;

&lt;p&gt;That's the GEO problem in one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed in the SERP
&lt;/h2&gt;

&lt;p&gt;Old SERP for most informational queries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────┐
│  [Ads]                              │
│  1. example.com/answer              │
│  2. another-site.com/guide          │
│  3. wikipedia.org/wiki/topic        │
│  4. ...                             │
└─────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;New SERP for the same query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────┐
│  [AI Overview]                      │
│   └─ synthesized answer             │
│   └─ 3-5 citation links (collapsed) │
│                                     │
│  [People Also Ask]                  │
│   └─ expands inline                 │
│                                     │
│  1. example.com/answer  ← you       │
│  2. another-site.com/guide          │
└─────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user gets an answer before they see your link. CTR drops. Impressions stay the same or grow. That gap is the GEO signal.&lt;/p&gt;

&lt;p&gt;On one site I run, impressions on an informational page grew 4x over three months while CTR dropped below 0.5% — the query cluster started triggering AI Overviews during that period. The page didn't lose rankings. It lost the click.&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO vs GEO — What Each Optimizes For
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;SEO&lt;/th&gt;
&lt;th&gt;GEO&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Target&lt;/td&gt;
&lt;td&gt;Ranking position&lt;/td&gt;
&lt;td&gt;Citation in AI summary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Signal&lt;/td&gt;
&lt;td&gt;Backlinks, authority, E-E-A-T&lt;/td&gt;
&lt;td&gt;Structure, clarity, entity match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Win condition&lt;/td&gt;
&lt;td&gt;User clicks your link&lt;/td&gt;
&lt;td&gt;AI quotes your content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metric&lt;/td&gt;
&lt;td&gt;Clicks, CTR&lt;/td&gt;
&lt;td&gt;Impressions → brand recall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Risk&lt;/td&gt;
&lt;td&gt;Algorithm update&lt;/td&gt;
&lt;td&gt;AI answers the query fully&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GEO and SEO aren't competing strategies. GEO is what happens to SEO when the SERP adds a layer above organic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Query Types Are Most Exposed
&lt;/h2&gt;

&lt;p&gt;Not every query gets an AI Overview. The exposure depends on query type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HIGH GEO EXPOSURE
─────────────────────────────────────────
  Informational   "what is X"
                  "how does X work"
                  "X explained"
                  "X rules / laws / limits"

MEDIUM GEO EXPOSURE
─────────────────────────────────────────
  Comparison      "X vs Y"
                  "best X for Y"
                  "X alternatives"

LOW GEO EXPOSURE
─────────────────────────────────────────
  Transactional   "buy X"
                  "X near me"
                  "book X in [city]"
                  "X price"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Transactional and local queries rarely trigger AI Overviews. Google still routes those to maps, commerce, and organic listings. That's where clicks live.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GEO-Optimized Content Looks Like
&lt;/h2&gt;

&lt;p&gt;AI systems appear to favor content that answers cleanly and early. The structure matters more than the word count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not this:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Introduction paragraph...
Background on the topic...
History and context...
What experts say...
[Actual answer buried at paragraph 6]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;This:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Direct answer in first 2 sentences]

## Key points
- Fact with number
- Fact with source signal ("as of 2026...")
- Specific limit, rule, or definition

[Supporting detail below]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AI Overview pulls from the top of the page. If your answer is buried, a cleaner competitor gets cited instead — even if you outrank them.&lt;/p&gt;

&lt;p&gt;Other signals that increase citation likelihood:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Defined terms in headings (&lt;code&gt;##&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Numbered lists with specific values (not vague)&lt;/li&gt;
&lt;li&gt;Dates and freshness markers&lt;/li&gt;
&lt;li&gt;Tables for comparison content&lt;/li&gt;
&lt;li&gt;No filler text between the H1 and the answer&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Downstream Intent Play
&lt;/h2&gt;

&lt;p&gt;Here's the strategic shift GEO forces: stop trying to own queries where AI answers the whole thing. Own what comes next.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User query: "what are the cannabis limits in Colorado"
     │
     ▼
AI Overview answers it fully
     │
     ▼
User now knows the limit — and wants to act on it
     │
     ├──► "420-friendly hotels in Colorado"      ← transactional
     ├──► "cannabis tours in Denver"             ← transactional
     └──► "where to buy in [city]"               ← local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The informational query feeds intent. The downstream query converts. GEO-aware content strategy maps the full path — not just the definition page, but every action a user might take after getting the answer.&lt;/p&gt;

&lt;p&gt;If your site only has the definition page, AI Overview cuts the journey short. If you have the definition page &lt;em&gt;and&lt;/em&gt; the action pages linked from it, you capture both the citation signal and the conversion traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical GEO Checklist
&lt;/h2&gt;

&lt;p&gt;Content structure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Direct answer in the first 2 sentences&lt;/li&gt;
&lt;li&gt;[ ] No more than one paragraph before the first heading&lt;/li&gt;
&lt;li&gt;[ ] Key facts in lists or tables, not buried in prose&lt;/li&gt;
&lt;li&gt;[ ] Dates and specifics (not "recently" — use the year)&lt;/li&gt;
&lt;li&gt;[ ] Headings match the exact phrasing of common questions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Internal linking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Every informational page links to a downstream action page&lt;/li&gt;
&lt;li&gt;[ ] Transactional pages don't depend on informational traffic alone&lt;/li&gt;
&lt;li&gt;[ ] Related guides section at the bottom of every post&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Monitoring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Track impressions vs clicks separately — divergence signals AI capture&lt;/li&gt;
&lt;li&gt;[ ] Flag pages where impressions grow but CTR drops below 1%&lt;/li&gt;
&lt;li&gt;[ ] Check which queries trigger AI Overviews for your target keywords&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Honest Summary
&lt;/h2&gt;

&lt;p&gt;GEO doesn't replace SEO work. Rankings still matter for non-AI-captured queries, and being cited in an AI Overview still requires ranking well enough to be in Google's index pull.&lt;/p&gt;

&lt;p&gt;What GEO changes is the &lt;em&gt;goal&lt;/em&gt; for different content types. Informational pages: optimize for citation and downstream linking, not for direct clicks. Transactional pages: optimize for clicks, those are still yours to win.&lt;/p&gt;

&lt;p&gt;The operators who will get hurt are the ones with sites built entirely on informational content with no action layer underneath. The ones who built the full funnel — definition → comparison → booking — are fine.&lt;/p&gt;

&lt;p&gt;Build the full funnel.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
