<?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: Evgenii Slepinin</title>
    <description>The latest articles on DEV Community by Evgenii Slepinin (@evgenii-slepinin).</description>
    <link>https://dev.to/evgenii-slepinin</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%2F4034006%2F0a81c463-98ad-4065-ad33-a8bb19bc7153.jpg</url>
      <title>DEV Community: Evgenii Slepinin</title>
      <link>https://dev.to/evgenii-slepinin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/evgenii-slepinin"/>
    <language>en</language>
    <item>
      <title>Verify the crawler, not its name: Web Bot Auth, IP checks, and the third gate</title>
      <dc:creator>Evgenii Slepinin</dc:creator>
      <pubDate>Sun, 19 Jul 2026 13:27:03 +0000</pubDate>
      <link>https://dev.to/evgenii-slepinin/verify-the-crawler-not-its-name-web-bot-auth-ip-checks-and-the-third-gate-4g9j</link>
      <guid>https://dev.to/evgenii-slepinin/verify-the-crawler-not-its-name-web-bot-auth-ip-checks-and-the-third-gate-4g9j</guid>
      <description>&lt;p&gt;Two posts ago I told people to check their server logs for AI crawlers getting a 403. &lt;a class="mentioned-user" href="https://dev.to/wrencalloway"&gt;@wrencalloway&lt;/a&gt; replied with the obvious next question: fine, you found the block - now how do you let the right crawlers through without letting everyone in?&lt;/p&gt;

&lt;p&gt;This is that post - and honestly, he co-wrote it. The three-gate model, the "identity isn't intent" framing, the rotation problem that breaks IP allowlists: those are his, pulled straight from our thread. I did the write-up. The thinking was a two-person job. Credited throughout.&lt;/p&gt;

&lt;p&gt;Upfront, so nobody mistakes this for a war story: I haven't shipped this on my own edge yet. It's research toward doing it properly. If you run this in production, the comments are where I learn something.&lt;/p&gt;

&lt;h2&gt;
  
  
  One question that's actually two
&lt;/h2&gt;

&lt;p&gt;You want AI crawlers to reach your content - that's how you get cited in ChatGPT, Perplexity and Gemini. You don't want every scraper on the internet walking in behind them.&lt;/p&gt;

&lt;p&gt;"Let the good ones in, keep the bad ones out" sounds like one job. It's two, and they pull against each other. Open the door wider for GPTBot and you've opened it for anything willing to type &lt;code&gt;GPTBot&lt;/code&gt; into a header. Close it and you vanish from the answers you were trying to appear in.&lt;/p&gt;

&lt;p&gt;The whole problem is identity: &lt;strong&gt;how do you know a request is really from the crawler it claims to be?&lt;/strong&gt; Everything below is three increasingly honest answers to that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Answer 1 - the name (and why it's worthless)
&lt;/h2&gt;

&lt;p&gt;The lazy fix: allow anything whose User-Agent contains &lt;code&gt;GPTBot&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A User-Agent is a string. Anyone can type it. A scraper labels itself &lt;code&gt;GPTBot&lt;/code&gt;, sails through, and your gesture toward AI visibility is now an open door for exactly the traffic you were keeping out.&lt;/p&gt;

&lt;p&gt;The name isn't identity. It's a claim. You need something the visitor can't fake.&lt;/p&gt;

&lt;h2&gt;
  
  
  Answer 2 - the origin (IP)
&lt;/h2&gt;

&lt;p&gt;Better: check where the request actually came from. Done right, it's a two-step DNS dance, not a flat IP lookup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reverse-DNS&lt;/strong&gt; the requesting IP → get a hostname&lt;/li&gt;
&lt;li&gt;Confirm the hostname belongs to the vendor - &lt;code&gt;*.crawl.openai.com&lt;/code&gt;, &lt;code&gt;*.anthropic.com&lt;/code&gt;, &lt;code&gt;*.perplexity.ai&lt;/code&gt;, &lt;code&gt;*.googlebot.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forward-resolve&lt;/strong&gt; that hostname → it must return the original IP&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj0m83e7baqcslews6hem.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj0m83e7baqcslews6hem.png" alt="Forward-confirm reverse DNS: an IP resolves to a hostname, the hostname must end in the vendor's domain, then it resolves back to the same IP. Any step failing means a spoofer." width="800" height="416"&gt;&lt;/a&gt;&lt;em&gt;Forward-confirm reverse DNS. A scraper can claim a hostname; it can't make the vendor's DNS resolve back to its IP.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A scraper can &lt;em&gt;claim&lt;/em&gt; a hostname. It can't make the vendor's DNS forward-resolve to its own IP. Both directions have to agree.&lt;/p&gt;

&lt;p&gt;Some vendors also publish raw ranges to match against:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI&lt;/strong&gt; - &lt;a href="https://openai.com/gptbot.json" rel="noopener noreferrer"&gt;gptbot.json&lt;/a&gt;, &lt;a href="https://openai.com/searchbot.json" rel="noopener noreferrer"&gt;searchbot.json&lt;/a&gt;, &lt;a href="https://openai.com/chatgpt-user.json" rel="noopener noreferrer"&gt;chatgpt-user.json&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Perplexity&lt;/strong&gt; - &lt;a href="https://www.perplexity.com/perplexitybot.json" rel="noopener noreferrer"&gt;perplexitybot.json&lt;/a&gt;, &lt;a href="https://www.perplexity.com/perplexity-user.json" rel="noopener noreferrer"&gt;perplexity-user.json&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google&lt;/strong&gt; - &lt;a href="https://developers.google.com/search/apis/ipranges/googlebot.json" rel="noopener noreferrer"&gt;crawler ranges&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Where origin breaks - three cracks, none small
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Not everyone publishes.&lt;/strong&gt; This one surprised me: &lt;strong&gt;Anthropic does not publish IP ranges for ClaudeBot.&lt;/strong&gt; The IPs in their docs are for the Claude &lt;em&gt;API&lt;/em&gt;, not the crawler. Their own guidance for identifying ClaudeBot is User-Agent plus reverse-DNS against &lt;code&gt;*.anthropic.com&lt;/code&gt;. So for one of the three biggest crawlers, "just match the published range" isn't an option that exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google won't separate training traffic by IP.&lt;/strong&gt; &lt;code&gt;Google-Extended&lt;/code&gt; - the control for Gemini and AI Overviews training - is a &lt;strong&gt;robots.txt token, not an IP set.&lt;/strong&gt; There's no &lt;code&gt;google-extended.json&lt;/code&gt;. You can allow or deny it in robots.txt, but you cannot distinguish it at the network layer. IP verification doesn't reach it at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rotation.&lt;/strong&gt; This is the crack &lt;a class="mentioned-user" href="https://dev.to/wrencalloway"&gt;@wrencalloway&lt;/a&gt; named, and it's the sharpest:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A stale allowlist and a 20-minute 403 window are both real costs, and "just poll the JSON more often" is a band-aid on a design that shouldn't have required polling.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every published range is a snapshot of a moving target. The vendor rotates, or ships a new block late, and you choose between an allowlist that's already wrong and a window where legitimate crawlers eat a 403. You're not verifying identity anymore. You're babysitting CIDR blocks and hoping the JSON is current.&lt;/p&gt;

&lt;h2&gt;
  
  
  Answer 3 - the proof (Web Bot Auth)
&lt;/h2&gt;

&lt;p&gt;This is where the trust model changes shape instead of just getting fussier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Web Bot Auth&lt;/strong&gt; - a Cloudflare-led IETF draft (&lt;a href="https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/" rel="noopener noreferrer"&gt;draft-meunier-web-bot-auth-architecture&lt;/a&gt;, built on RFC 9421 HTTP Message Signatures) - has each crawler &lt;strong&gt;sign its requests with an Ed25519 key.&lt;/strong&gt; The public key lives at a well-known directory on the vendor's own domain. You verify the signature. Identity is proven by cryptography, not inferred from where the packet came from.&lt;/p&gt;

&lt;p&gt;The shift is the entire point: from &lt;em&gt;"where did this connection originate"&lt;/em&gt; to &lt;em&gt;"can you prove you hold the key."&lt;/em&gt; That survives rotation. It doesn't care which datacenter the request left. No CIDR to babysit, no JSON to poll.&lt;/p&gt;

&lt;p&gt;It's real and moving fast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Integrated into &lt;strong&gt;Cloudflare's Verified Bots&lt;/strong&gt; program, with a &lt;strong&gt;Signed Agents&lt;/strong&gt; directory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ChatGPT's agent&lt;/strong&gt; was in the first signed cohort, 2025&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS WAF&lt;/strong&gt; added Web Bot Auth support in late 2025, auto-allowing verified agents&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Status, honestly: &lt;strong&gt;an active IETF draft, not a ratified standard.&lt;/strong&gt; But with Cloudflare, OpenAI, Anthropic and AWS moving in lockstep, it's already the de-facto direction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where proof breaks
&lt;/h3&gt;

&lt;p&gt;It only helps for crawlers that actually sign. And the scrapers you most want to block are exactly the ones who'll never adopt it. Signatures verify the honest. They do nothing to the dishonest.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why not just wait for signatures to win?
&lt;/h2&gt;

&lt;p&gt;Because signatures answer a narrower question than it looks. Here's the reframing &lt;a class="mentioned-user" href="https://dev.to/wrencalloway"&gt;@wrencalloway&lt;/a&gt; landed on, and it's the sharpest thing in either of our threads:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Signatures move the question from "who are you" to "what are you doing."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Sit with that. &lt;strong&gt;A valid signature proves identity, not intent.&lt;/strong&gt; A legitimate crawler having a bad day and a stolen key behave identically at the signature layer - both present a valid signature. Verification hands you a &lt;em&gt;name&lt;/em&gt;. It says nothing about whether the holder of that name is currently hammering your origin at 400 requests a second.&lt;/p&gt;

&lt;p&gt;Which means there is always a floor under everything else: behaviour. Rate limits, request patterns, what a client reaches for once it's through the door. Verification gets you a name. &lt;strong&gt;Behaviour decides whether the name gets to keep acting like one.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The model: three gates, not one
&lt;/h2&gt;

&lt;p&gt;Put the three answers in order and they stop competing. They stack.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Signed?&lt;/strong&gt; Verify the signature → fast-path it. &lt;em&gt;(Web Bot Auth - proof)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not signed, but claims a known crawler?&lt;/strong&gt; Reverse-DNS + forward-confirm, or a published range. &lt;em&gt;(IP - origin)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Through either gate - now watch it.&lt;/strong&gt; Rate limits and behaviour, always on, for everyone. &lt;em&gt;(the floor)&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdygnq06b12bi1bsuzopj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdygnq06b12bi1bsuzopj.png" alt="Three-gate verification: a request checks for a Web Bot Auth signature first, then IP origin via reverse-DNS, then falls to an always-on behaviour layer that also monitors allowed traffic. Identity proves who; only behaviour proves what." width="800" height="480"&gt;&lt;/a&gt;&lt;em&gt;Three gates, not one: proof, then origin, then an always-on behaviour floor. Identity proves who; only behaviour proves what.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a class="mentioned-user" href="https://dev.to/wrencalloway"&gt;@wrencalloway&lt;/a&gt;'s word for this was &lt;em&gt;additive, not a replacement&lt;/em&gt;, and once you see the gates it's obvious why. Signatures don't replace the IP layer - they &lt;strong&gt;shrink its blast radius.&lt;/strong&gt; Every honest crawler that signs steps out of the pool you're forced to judge by circumstance. What's left for the IP-and-vibes gate is smaller and dirtier - a population that's mostly bad actors, which is a much easier one to be aggressive with. And behind both, behaviour never stops watching, because identity was never the same thing as intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spotting a fake while the world catches up
&lt;/h2&gt;

&lt;p&gt;Until signatures are universal, most of the work is catching User-Agent liars. The tells are consistent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reverse-resolves to a general host (Hetzner, OVH, DigitalOcean) with &lt;strong&gt;no vendor match&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NXDOMAIN&lt;/strong&gt; - no reverse-DNS at all&lt;/li&gt;
&lt;li&gt;Goes straight for &lt;code&gt;/admin&lt;/code&gt;, &lt;code&gt;/.env&lt;/code&gt;, &lt;code&gt;/wp-login&lt;/code&gt; - real crawlers read content, not secrets&lt;/li&gt;
&lt;li&gt;Hundreds of requests a minute, no Crawl-Delay&lt;/li&gt;
&lt;li&gt;A typo in the name: &lt;code&gt;GPT-Bot&lt;/code&gt;, or a version that doesn't exist (&lt;code&gt;ClaudeBot/2.x&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None is proof alone. Together they're a scraper wearing a badge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I took away
&lt;/h2&gt;

&lt;p&gt;The whole progression is one idea getting more honest with itself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The name&lt;/strong&gt; asks the visitor to identify themselves. Trusts a string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The origin&lt;/strong&gt; asks where they came from. Trusts an address that rotates or goes unpublished.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The proof&lt;/strong&gt; asks them to prove they hold a key. Trusts math - but only about &lt;em&gt;who&lt;/em&gt;, never &lt;em&gt;what&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each gate moves trust closer to something the visitor can't fake. None of them is the finish line, because the finish line doesn't exist: even perfect identity tells you nothing about intent. You verify by proof where you can, by origin where you can't, and you hold the line with behaviour for everyone - because a name was never a promise of good behaviour.&lt;/p&gt;

&lt;p&gt;I haven't built this yet. When I do, it's signatures first, IP as fallback, behaviour underneath - and I'll write up what broke, because something always does. If you're already running it, tell me what I'm underestimating. Last time that's exactly how this post got written.&lt;/p&gt;




&lt;p&gt;Related reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://seo7.es/en/blog/ai-seo-blog/your-html-is-fine-the-cdn-still-blocks-the-bot" rel="noopener noreferrer"&gt;Your HTML is fine. The CDN still blocks the bot.&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://seo7.es/en/blog/ai-seo-blog/ranks-1-on-google-invisible-to-chatgpt" rel="noopener noreferrer"&gt;Your page ranks #1 on Google and is invisible to ChatGPT&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>seo</category>
      <category>webdev</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Your HTML is fine. The CDN still blocks the bot.</title>
      <dc:creator>Evgenii Slepinin</dc:creator>
      <pubDate>Sat, 18 Jul 2026 18:22:55 +0000</pubDate>
      <link>https://dev.to/evgenii-slepinin/your-html-is-fine-the-cdn-still-blocks-the-bot-466d</link>
      <guid>https://dev.to/evgenii-slepinin/your-html-is-fine-the-cdn-still-blocks-the-bot-466d</guid>
      <description>&lt;p&gt;This started as a comment &lt;a class="mentioned-user" href="https://dev.to/wrencalloway"&gt;@wrencalloway&lt;/a&gt; left on my last post. It was sharp enough that it deserved more than a reply — so here's the full version.&lt;/p&gt;

&lt;p&gt;You did the work. The page is server-rendered. The JSON-LD is in the raw response. &lt;code&gt;curl&lt;/code&gt; returns the whole article, headline and all. A crawler that fetches your URL gets everything it needs.&lt;/p&gt;

&lt;p&gt;Except the crawler never fetches your URL. It asks the CDN, and the CDN says 403.&lt;/p&gt;

&lt;p&gt;Your content is perfect and unreachable. This is the layer underneath the one everyone talks about — and it's invisible in every tool you'd normally reach for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two different kinds of "no"
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;robots.txt is a note taped to the door.&lt;/strong&gt; It says "please don't come in." A polite crawler reads it and turns around. A rude one ignores it and walks straight past. Either way, the note never touches your bytes — it's a request, enforced entirely by the visitor's own manners.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A WAF or CDN rule is the door.&lt;/strong&gt; It answers the request itself, at the edge, before anything reaches your origin: &lt;code&gt;403 Forbidden&lt;/code&gt;, &lt;code&gt;429 Too Many Requests&lt;/code&gt;, or a JavaScript challenge the bot can't solve. The HTML behind that door could be a masterpiece or a blank page. The bot sees neither. It sees the status code.&lt;/p&gt;

&lt;p&gt;People spend weeks perfecting the note and never check whether the door is locked.&lt;/p&gt;

&lt;h2&gt;
  
  
  This isn't hypothetical — here's the data
&lt;/h2&gt;

&lt;p&gt;Cloudflare Radar publishes what actually happens to AI-crawler traffic across its network. From the AI Insights dashboard (7-day view, as of 18 July 2026):&lt;/p&gt;

&lt;p&gt;Of all HTTP responses served to AI bots and crawlers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;200 OK — 73.6%&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;403 Forbidden — 5.2%&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;429 Too Many Requests — 1.3%&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;503 — 1.1%&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://radar.cloudflare.com/ai-insights#ai-bot-crawler-traffic" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F16kqjzs2vbqy4zhty0b9.png" alt="Cloudflare Radar — AI crawler response status: 200 73.6%, 403 5.2%, 429 1.3%, 503 1.1%" width="800" height="439"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;AI-crawler response codes. Source: Cloudflare Radar, AI Insights, 7-day view, 18 Jul 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Read that again. &lt;strong&gt;More than one in fifteen requests from AI crawlers is being actively refused at the edge&lt;/strong&gt; — 403 or 429 — before it ever touches the content. Not deprioritised. Refused.&lt;/p&gt;

&lt;p&gt;And most of that isn't a decision. It's a default — a managed WAF ruleset, a "block AI bots" toggle flipped in 2024, a bot-fight score set too high. The same dashboard's robots.txt tracker shows GPTBot, CCBot and ClaudeBot as the most-disallowed crawlers across the top domains — much of it inherited, not authored.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://radar.cloudflare.com/ai-insights#ai-bot-crawler-traffic" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1wlk8oiiwum7e9o7iszx.png" alt="Cloudflare Radar — AI user agents most disallowed in robots.txt: GPTBot, CCBot, ClaudeBot lead" width="800" height="646"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Most-disallowed AI user agents across top domains. Source: Cloudflare Radar, AI Insights, as of 18 Jul 2026.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why you will never notice on your own
&lt;/h2&gt;

&lt;p&gt;Open your site. It loads. Of course it does — your browser isn't on the blocklist. Residential IP, Chrome User-Agent, you pass every check.&lt;/p&gt;

&lt;p&gt;Open robots.txt. &lt;code&gt;Allow: /&lt;/code&gt;. Looks perfectly welcoming.&lt;/p&gt;

&lt;p&gt;View source. Clean HTML, structured data, the works. (You already fixed that part.)&lt;/p&gt;

&lt;p&gt;Every tool you'd reach for confirms you're fine — because every one of them comes from &lt;strong&gt;you&lt;/strong&gt;, not from GPTBot. The block is aimed at a class of visitor you are never going to be.&lt;/p&gt;
&lt;h2&gt;
  
  
  The only place it shows: server logs
&lt;/h2&gt;

&lt;p&gt;The browser lies, because the browser is you. robots.txt lies, because it's advisory. The access log doesn't lie. It has one row per real request, with the visitor's User-Agent and the exact status you returned. It's the only record of what GPTBot actually got.&lt;/p&gt;

&lt;p&gt;Grep for the ones that matter and count their status codes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"GPTBot|OAI-SearchBot|ChatGPT-User|ClaudeBot|Claude-SearchBot|PerplexityBot|Google-Extended"&lt;/span&gt; access.log &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $9}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;200&lt;/code&gt; means it got the page. &lt;code&gt;403&lt;/code&gt; means the edge refused it. &lt;code&gt;429&lt;/code&gt; means you rate-limited it into silence. The failure mode nobody catches looks like this: robots.txt wide open, logs full of 403s. Everything you can see says yes; the thing that decides says no.&lt;/p&gt;

&lt;p&gt;I've read this exact story more than once. Months of content, zero citations in ChatGPT or Perplexity, and an audit that finally finds Cloudflare had been handing GPTBot a 403 the entire time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap inside the fix
&lt;/h2&gt;

&lt;p&gt;The obvious repair — allow anything whose User-Agent contains &lt;code&gt;GPTBot&lt;/code&gt; — is wrong, because a User-Agent is a string anyone can type. A price scraper labels itself &lt;code&gt;GPTBot&lt;/code&gt; and strolls through your generous new rule.&lt;/p&gt;

&lt;p&gt;Cloudflare's own transparency tracking makes the point: it flags which operators can even be verified. As of mid-2026, &lt;strong&gt;ByteDance shows as unverified&lt;/strong&gt; — which is exactly why User-Agent alone can't be trusted.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://radar.cloudflare.com/ai-insights#ai-bot-crawler-traffic" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg5rpv8h1imsvw9m0wvvd.png" alt="Cloudflare Radar — AI bot transparency: Amazon, Anthropic, Google, OpenAI verified; ByteDance not" width="800" height="464"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;AI bot transparency tracking. Source: Cloudflare Radar, AI Insights, as of 18 Jul 2026.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So real verification isn't the name. It's the origin. Two ways:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Verify by published IP ranges.&lt;/strong&gt; OpenAI, Anthropic and the rest publish their crawler IPs — &lt;code&gt;openai.com/gptbot.json&lt;/code&gt;, &lt;code&gt;openai.com/searchbot.json&lt;/code&gt;, &lt;code&gt;openai.com/chatgpt-user.json&lt;/code&gt;, and their equivalents. Reverse-DNS the requesting IP, confirm the hostname belongs to the vendor, then forward-resolve it back to the same IP. If either step fails, it's a spoofer wearing the name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Verify by cryptographic signature.&lt;/strong&gt; This is where it's heading. &lt;strong&gt;Web Bot Auth&lt;/strong&gt; — a Cloudflare-led IETF draft (&lt;code&gt;draft-meunier-web-bot-auth-architecture&lt;/code&gt;, built on RFC 9421) — has crawlers sign each request with an Ed25519 key, so identity is proven, not claimed. It's integrated into Cloudflare's Verified Bots program with a dedicated Signed Agents directory, and ChatGPT's agent was in the first signed cohort in 2025. It isn't only Cloudflare, either — AWS WAF added Web Bot Auth support in late 2025, auto-allowing verified agents.&lt;/p&gt;

&lt;p&gt;Note the status honestly: Web Bot Auth is an &lt;strong&gt;active IETF draft, not a ratified standard&lt;/strong&gt;. But with Cloudflare, OpenAI, Anthropic and AWS moving in lockstep, it's already the de-facto direction.&lt;/p&gt;

&lt;p&gt;The point for you: "block the bad bots" and "let the AI in" are the same problem, and the User-Agent solves neither. Verify by origin or by signature — otherwise you block the crawler you want and admit the one you don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Check the logs, not the browser.&lt;/strong&gt; Pull a week of access logs, filter to the AI User-Agents, look at the status codes. That's the whole audit — an afternoon, not a project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you see 403/429, find the rule.&lt;/strong&gt; It's almost always a managed ruleset, a "block AI bots" setting, or an over-eager bot-fight score — not a line you wrote. Allowlist the crawlers you want by verified IP or Web Bot Auth, never by User-Agent alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I took away
&lt;/h2&gt;

&lt;p&gt;Your HTML is the last thing in the request's path, not the first. Before a bot reads a single byte of it, the request has to clear DNS, the CDN, the WAF, the rate limiter, the bot-management score. Any one of them can end the request with a status code you never see — on a page that renders flawlessly for you.&lt;/p&gt;

&lt;p&gt;"It works in my browser" was always a weak claim. For crawlers it isn't even the right question. The right question is: &lt;strong&gt;what status code did the bot get?&lt;/strong&gt; And the only honest answer is in the logs.&lt;/p&gt;




&lt;p&gt;Related reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://seo7.es/en/blog/ai-seo-blog/ranks-1-on-google-invisible-to-chatgpt" rel="noopener noreferrer"&gt;Your page ranks #1 on Google and is invisible to ChatGPT&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://seo7.es/en/proyecto/yoga-praktika" rel="noopener noreferrer"&gt;The full case: a Django SSR rebuild, 0 to 116 indexed pages&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>ai</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>RAG is for finding. Full context is for deciding.</title>
      <dc:creator>Evgenii Slepinin</dc:creator>
      <pubDate>Fri, 17 Jul 2026 20:39:39 +0000</pubDate>
      <link>https://dev.to/evgenii-slepinin/rag-is-for-finding-full-context-is-for-deciding-315c</link>
      <guid>https://dev.to/evgenii-slepinin/rag-is-for-finding-full-context-is-for-deciding-315c</guid>
      <description>&lt;p&gt;The advice in 2026 is settled: chunk your documents, embed them, retrieve top-k, feed those to the model. Don't waste context. Don't waste tokens.&lt;/p&gt;

&lt;p&gt;I built a system that deliberately does none of that. Every query gets the expert's entire CV — 69 projects, publications, education, languages. Roughly 34,000 characters, in full, every time.&lt;/p&gt;

&lt;p&gt;Here's the reasoning, because it wasn't laziness.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task decides the architecture
&lt;/h2&gt;

&lt;p&gt;The system finds UN tenders for one specific expert. Every day it crawls 28 sources, normalises and deduplicates thousands of procurement notices, then answers one question per tender: does this person fit?&lt;/p&gt;

&lt;p&gt;That looks like retrieval. It isn't. It's exclusion.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval&lt;/strong&gt; asks: what in this corpus is most similar to the query?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclusion&lt;/strong&gt; asks: is there anything here that disqualifies this?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are opposite operations. The first wants the strongest matches. The second needs the weakest link — and by definition, the weakest link is the thing least similar to everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why similarity would have failed
&lt;/h2&gt;

&lt;p&gt;A real pattern from the data.&lt;/p&gt;

&lt;p&gt;A tender is titled &lt;code&gt;Public Financial Management&lt;/code&gt;. The expert has a dozen public-finance projects. Cosine similarity between tender and profile: very high.&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="c1"&gt;# what a RAG pipeline does here
&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tender&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;corpus&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cv_chunks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# returns: finance_2019, finance_2021, education_economics
# verdict: strong match
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tender is for an environmental officer. It says so once, on page five, in the responsibilities section.&lt;/p&gt;

&lt;p&gt;Or: the role fits perfectly, but requires a national consultant with a mandatory local language. One sentence, buried in eligibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The disqualifying detail is never the semantically closest passage.&lt;/strong&gt; That's exactly what makes it disqualifying — it's the part that doesn't match the pattern. A retrieval system is optimised to not surface it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What chunking destroys
&lt;/h2&gt;

&lt;p&gt;Split a CV into chunks and you keep the facts but lose the shape.&lt;/p&gt;

&lt;p&gt;Retrieve three chunks about public finance and the model sees an expert in public finance. Send all 69 projects and the model sees which countries he's actually worked in, which languages he actually has, what seniority he operates at, and — critically — what he has &lt;strong&gt;never&lt;/strong&gt; done.&lt;/p&gt;

&lt;p&gt;Absence is information. And absence cannot be retrieved. There is no chunk that says "this person has no environmental background". You only learn it by reading everything and noticing nothing is there.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;RAG can tell you what's in a document. It can't tell you what isn't.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The economics I was told to fear
&lt;/h2&gt;

&lt;p&gt;34,000 characters is roughly 9,000 tokens of context per judgement. Multiplied by hundreds of tenders a day, this is supposed to be the expensive mistake.&lt;/p&gt;

&lt;p&gt;It isn't. The AI and the proxies together run on a few dollars a month.&lt;/p&gt;

&lt;p&gt;Two reasons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The prefilter does the cheap work first.&lt;/strong&gt; Stop-words, theme, territory, language, deadline, expired — all in code, no model calls. The AI only ever sees candidates that survived the rules.&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="c1"&gt;# rules run first, model runs last
&lt;/span&gt;&lt;span class="n"&gt;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tenders&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;passes_rules&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="c1"&gt;# cheap: no model calls
&lt;/span&gt;
&lt;span class="n"&gt;verdicts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;judge_with_full_cv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="c1"&gt;# expensive, but few
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. The CV is byte-identical on every call.&lt;/strong&gt; Same prefix, over and over — the ideal shape for prompt caching.&lt;/p&gt;

&lt;p&gt;The panel tracks cost per run and cost per &lt;em&gt;found&lt;/em&gt; tender, so this isn't a guess. I watch the number.&lt;/p&gt;

&lt;p&gt;The generalisable lesson: token cost is a real constraint, but it's one people apply from memory rather than measurement. Prices moved. Most RAG pipelines I see were designed for an economics that no longer exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the model returns
&lt;/h2&gt;

&lt;p&gt;Not prose. A structured verdict, logged in full:&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;"fits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"short_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"national consultant required"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"long_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Role matches PFM background, but
    eligibility restricts applicants to nationals
    with working Portuguese."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"country"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mozambique"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tor_attached"&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;It judges the substance of the role, not its title. That's the entire point.&lt;/p&gt;

&lt;h2&gt;
  
  
  When I would use RAG — and do
&lt;/h2&gt;

&lt;p&gt;This is not an argument against RAG. It's an argument against defaulting to it.&lt;/p&gt;

&lt;p&gt;RAG is right when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the corpus is &lt;strong&gt;unbounded&lt;/strong&gt; — a knowledge base that grows forever, docs, support history&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;latency&lt;/strong&gt; matters&lt;/li&gt;
&lt;li&gt;the task genuinely &lt;strong&gt;is&lt;/strong&gt; retrieval: find the passage, answer from it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I use RAG on other projects for exactly those reasons. Different task, different shape, different answer.&lt;/p&gt;

&lt;p&gt;The honest caveat on my side: long context isn't free of problems either. Models attend less reliably to the middle of a long prompt than to its start and end. 34k characters sits comfortably inside what current models handle well. At 300k I'd be having a different conversation, and I'd probably be building a hybrid.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I ended up with
&lt;/h2&gt;

&lt;p&gt;Ask what the model has to &lt;strong&gt;do&lt;/strong&gt; with the document.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Find something&lt;/strong&gt; → retrieve. The corpus can be enormous, and finding is what embeddings are good at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judge something&lt;/strong&gt; → give it everything. Judgement needs the whole picture, including the parts that aren't relevant, because irrelevance is half the verdict.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most business tasks people are wiring RAG into are judgement tasks wearing a retrieval costume. &lt;em&gt;Does this candidate fit. Is this contract risky. Should we bid on this.&lt;/em&gt; In every one of those, the thing that decides the answer is the detail nobody would have retrieved.&lt;/p&gt;




&lt;p&gt;Related reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://seo7.es/en/proyecto/tender" rel="noopener noreferrer"&gt;The full tender system: 28 sources, 17 connectors, self-repairing parsers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://seo7.es/en/servicios/inteligencia-artificial" rel="noopener noreferrer"&gt;AI and automation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>python</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Your page ranks #1 on Google and is invisible to ChatGPT. Both are true.</title>
      <dc:creator>Evgenii Slepinin</dc:creator>
      <pubDate>Fri, 17 Jul 2026 19:59:08 +0000</pubDate>
      <link>https://dev.to/evgenii-slepinin/your-page-ranks-1-on-google-and-is-invisible-to-chatgpt-both-are-true-3lg4</link>
      <guid>https://dev.to/evgenii-slepinin/your-page-ranks-1-on-google-and-is-invisible-to-chatgpt-both-are-true-3lg4</guid>
      <description>&lt;p&gt;There's an assumption sitting quietly inside most engineering teams: if Google can crawl and render our site, AI crawlers can too.&lt;/p&gt;

&lt;p&gt;It's wrong. And it's the single most expensive wrong assumption in technical SEO right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap
&lt;/h2&gt;

&lt;p&gt;Googlebot renders JavaScript. It has done for years — the page goes into a render queue, headless Chromium executes the scripts, the result gets indexed. Deferred, but it works.&lt;/p&gt;

&lt;p&gt;AI crawlers don't render anything.&lt;/p&gt;

&lt;p&gt;A server-log study by &lt;strong&gt;Vercel and MERJ&lt;/strong&gt;, published 17 December 2024, found that none of the major AI crawlers execute JavaScript. That includes OpenAI's &lt;code&gt;GPTBot&lt;/code&gt;, &lt;code&gt;OAI-SearchBot&lt;/code&gt; and &lt;code&gt;ChatGPT-User&lt;/code&gt;, and Anthropic's &lt;code&gt;ClaudeBot&lt;/code&gt;. A separate analysis of over 500 million GPTBot fetches found zero evidence of JavaScript execution.&lt;/p&gt;

&lt;p&gt;The detail I find most telling: these crawlers &lt;strong&gt;do download&lt;/strong&gt; JavaScript files. GPTBot in roughly 11.5% of requests, ClaudeBot in roughly 23.84%. They fetch the scripts and never run them.&lt;/p&gt;

&lt;p&gt;There's no partial credit here. Either your content is in the initial HTML response, or it isn't.&lt;/p&gt;

&lt;p&gt;The one exception is Google Gemini, which rides on Googlebot's Web Rendering Service and inherits its ability to execute JS — along with all its queue delays.&lt;/p&gt;

&lt;h2&gt;
  
  
  So both things are true at once
&lt;/h2&gt;

&lt;p&gt;Your React site ranks on page one. Google rendered it, indexed it, ranks it.&lt;/p&gt;

&lt;p&gt;Then a customer asks ChatGPT the exact question your page answers, and you're not in the response. Nothing is wrong with your rankings. The crawler received an empty shell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"root"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/assets/index-a1b2c3.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You are simultaneously first and invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap inside the trap: structured data
&lt;/h2&gt;

&lt;p&gt;This is where it gets specific.&lt;/p&gt;

&lt;p&gt;You build a React site. You add &lt;code&gt;react-helmet&lt;/code&gt;. You inject JSON-LD through it — Organization, LocalBusiness, FAQPage, the full graph:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;You open DevTools, expand &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, and there it is. Beautiful markup. Job done.&lt;/p&gt;

&lt;p&gt;It isn't done. &lt;strong&gt;DevTools shows you the rendered DOM&lt;/strong&gt; — the page after JavaScript has run. That is not what a crawler receives.&lt;/p&gt;

&lt;p&gt;Open &lt;code&gt;view-source:&lt;/code&gt; instead. Not the Elements panel. If your JSON-LD only exists in DevTools and not in view-source, then GPTBot, ClaudeBot and PerplexityBot never see it.&lt;/p&gt;

&lt;p&gt;Google will still find it, because Google renders. That's why this failure is so quiet — every tool you'd normally check with says you're fine.&lt;/p&gt;

&lt;p&gt;One nuance worth knowing: content doesn't have to be visible prose to count. Data embedded in the initial HTML as inline JSON or server-rendered payload &lt;strong&gt;is&lt;/strong&gt; in the raw response and readable. What's missed is specifically what the browser builds after load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this compounds
&lt;/h2&gt;

&lt;p&gt;Around 92% of ChatGPT agent queries rely on Bing's index — and Bingbot's JavaScript rendering is limited. So a client-side rendered SPA risks being absent from Bing &lt;strong&gt;and&lt;/strong&gt; from every AI crawler that queries it. Two failures, one root cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test takes thirty seconds
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://you.com/page | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"ld+json"&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://you.com/page | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"your headline text"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;grep&lt;/code&gt; returns nothing, neither does the crawler.&lt;/p&gt;

&lt;p&gt;Do it on your pricing page, your service pages, your best article. Those are the pages with the most to lose.&lt;/p&gt;

&lt;h2&gt;
  
  
  One layer deeper: the CDN
&lt;/h2&gt;

&lt;p&gt;Everything above assumes the crawler receives the same response you do. It might not.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;curl&lt;/code&gt; test above uses your User-Agent and your IP. A CDN or edge middleware can serve differently based on User-Agent, headers, or geography — and AI bots come from datacenter IPs with declared bot UAs, which is exactly the traffic a WAF is tuned to challenge. Your HTML can be perfect and GPTBot still gets a 403, a challenge page, or a stale cache entry before it ever reaches the content.&lt;/p&gt;

&lt;p&gt;So test as the bot, not as yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"GPTBot"&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; https://you.com/page | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"ld+json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if you're on Cloudflare, the honest check is your server logs: filter by the AI crawlers' User-Agents and read the response codes. 200 means they see you. 403, a redirect, or a challenge means they don't — and no view-source will reveal it, because you're looking from a browser with a human fingerprint.&lt;/p&gt;

&lt;p&gt;robots.txt doesn't save you here either: the WAF fires before robots.txt is consulted. Permission and access are different layers, and both fail silently.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(This section came out of a sharp comment from Wren Calloway below — worth reading the thread.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is not new
&lt;/h2&gt;

&lt;p&gt;Server-side rendering, static generation, or prerendering. Next.js, Nuxt, Angular Universal, Django SSR — the framework matters less than the principle: &lt;strong&gt;put the real content in the HTML the server sends.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On our own platform the JSON-LD is rendered directly into the DOM rather than injected through Helmet, and key pages are prerendered. Not because it's elegant — because it's the difference between having schema and having schema anyone can read.&lt;/p&gt;

&lt;p&gt;This is the same advice that's been correct for ten years. What changed is the cost of ignoring it. It used to mean waiting on Google's render queue. Now it means being absent from answers entirely, in the fastest-growing discovery channel there is.&lt;/p&gt;




&lt;p&gt;Related reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://seo7.es/en/proyecto/yoga-praktika" rel="noopener noreferrer"&gt;The Django SSR rebuild that took a site from 0 to 116 indexed pages&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://seo7.es/en/servicios/ai_seo" rel="noopener noreferrer"&gt;AI visibility / GEO&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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