<?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>Forem: Ted</title>
    <description>The latest articles on Forem by Ted (@henry_dan_81513dd35a2f540).</description>
    <link>https://forem.com/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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2256153%2Fd3b27e5a-9e82-4c4d-b481-9b835d4deea3.png</url>
      <title>Forem: Ted</title>
      <link>https://forem.com/henry_dan_81513dd35a2f540</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/henry_dan_81513dd35a2f540"/>
    <language>en</language>
    <item>
      <title>A Vercel Catch-All Rewrite Caused 190 Pages to Canonicalize to the Homepage</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Tue, 26 May 2026 07:56:21 +0000</pubDate>
      <link>https://forem.com/henry_dan_81513dd35a2f540/a-vercel-catch-all-rewrite-caused-190-pages-to-canonicalize-to-the-homepage-4mba</link>
      <guid>https://forem.com/henry_dan_81513dd35a2f540/a-vercel-catch-all-rewrite-caused-190-pages-to-canonicalize-to-the-homepage-4mba</guid>
      <description>&lt;p&gt;I run a React/Vite SPA deployed on Vercel. The site had been live for months. GSC was showing 190+ pages in the "Discovered — currently not indexed" bucket. Not penalised, not crawled and rejected — just never indexed.&lt;/p&gt;

&lt;p&gt;The cause turned out to be one line in &lt;code&gt;vercel.json&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a catch-all rewrite breaks indexing
&lt;/h2&gt;

&lt;p&gt;Vercel needs to know what to serve when someone hits a client-side route like &lt;code&gt;/city/denver&lt;/code&gt; directly. Since there's no &lt;code&gt;dist/city/denver/index.html&lt;/code&gt;, the default behavior is to rewrite all unmatched paths to &lt;code&gt;dist/index.html&lt;/code&gt; — the homepage shell.&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;"rewrites"&lt;/span&gt;&lt;span class="p"&gt;:&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;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;"/(.*)"&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;"/index.html"&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;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;The homepage shell has a canonical tag:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;So when Googlebot hits &lt;code&gt;/city/denver&lt;/code&gt;, it receives the homepage HTML and reads &lt;code&gt;canonical: https://example.com/&lt;/code&gt;. On a low-trust domain, Google appeared to deprioritize further crawling of those routes — treating them as duplicates of the homepage rather than returning to index them independently.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────────────────────────────────────┐
│              WHAT GOOGLEBOT SAW                          │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  GET /city/denver                                        │
│       │                                                  │
│       ▼                                                  │
│  Vercel catch-all: serves dist/index.html               │
│       │                                                  │
│       ▼                                                  │
│  &amp;lt;link rel="canonical" href="https://example.com/" /&amp;gt;   │
│       │                                                  │
│       ▼                                                  │
│  Google: signals duplicate of homepage                  │
│          deprioritizes further crawling                  │
│                                                          │
│  Result: 190 pages sitting in "Discovered, not indexed" │
└──────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is to generate a real &lt;code&gt;dist/[path]/index.html&lt;/code&gt; for every route before Vercel deploys. That way the catch-all never fires for known routes — Vercel serves the real file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prerender system
&lt;/h2&gt;

&lt;p&gt;The build command:&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="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vite build &amp;amp;&amp;amp; node scripts/prerender.mjs"&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;prerender.mjs&lt;/code&gt; runs after Vite. It walks &lt;code&gt;STATIC_ROUTES&lt;/code&gt; — a flat array of every known path — and writes a proper &lt;code&gt;dist/[path]/index.html&lt;/code&gt; for each one. Each file gets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A correct &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; for the route&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;meta name="description"&amp;gt;&lt;/code&gt; with real content&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt; pointing to the actual URL&lt;/li&gt;
&lt;li&gt;Injected body content inside &lt;code&gt;#root&lt;/code&gt; so Googlebot sees real text without executing JS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For detail pages, the script fetches live data from the database at build time and writes real names, ratings, addresses, and descriptions into the HTML.&lt;/p&gt;

&lt;p&gt;If a path is in &lt;code&gt;STATIC_ROUTES&lt;/code&gt;, Vercel finds the pre-built file and serves it directly. The catch-all only fires for paths that genuinely don't exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4-commit audit
&lt;/h2&gt;

&lt;p&gt;Once I confirmed the mechanism, I audited every route type and ran four commits in sequence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Commit 1 — Blog slugs
&lt;/h3&gt;

&lt;p&gt;Seven blog posts were missing from &lt;code&gt;STATIC_ROUTES&lt;/code&gt;. They had been published directly without updating the prerender list. Every one of them was being served as the homepage.&lt;/p&gt;

&lt;p&gt;Added the slugs, rewrote thin bodies on three entries using GSC impression data — queries with 60–70 impressions and zero clicks, showing Google was finding the topic but landing on content that couldn't hold it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Commit 2 — Crawl budget
&lt;/h3&gt;

&lt;p&gt;The site had out-of-state listing pages — pages for locations outside the primary geography with no real search demand. They weren't indexed but they were being crawled on every Googlebot pass.&lt;/p&gt;

&lt;p&gt;Added &lt;code&gt;noindex: true&lt;/code&gt; flag support to the HTML generator and set seven of these pages to noindex. Googlebot stops spending crawl budget on them.&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;function&lt;/span&gt; &lt;span class="nf"&gt;generateHtml&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;noindex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;robotsMeta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;noindex&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;meta name="robots" content="noindex, nofollow" /&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;meta name="robots" content="index, follow" /&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight additional ghost-town pages were also noindexed — locations with no tourism infrastructure, no search volume, and no database data to populate them with.&lt;/p&gt;

&lt;h3&gt;
  
  
  Commit 3 — City pages
&lt;/h3&gt;

&lt;p&gt;29 city-level pages existed in &lt;code&gt;STATIC_ROUTES&lt;/code&gt; but had generic one-sentence bodies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browse listings in Denver.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replaced each with unique content — elevation, specific listing names, local context, tourism details. Not template-swapped: each city got its own paragraph based on what made it distinct.&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                            │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  BEFORE                                                  │
│  "Browse listings in Denver."                           │
│                                                          │
│  AFTER                                                   │
│  "Denver sits at 5,280 feet. [Listing A] in RiNo and   │
│  [Listing B] near Capitol Hill are highest-rated.       │
│  Most visitors from sea level report altitude effects   │
│  within the first day — start low."                     │
│                                                          │
└─────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Commit 4 — Detail page enrichment
&lt;/h3&gt;

&lt;p&gt;Individual listing pages were getting auto-generated one-liners. The prerender already had the database connection — it just wasn't using it for detail pages.&lt;/p&gt;

&lt;p&gt;Added two enrichment loops that pull real data per slug:&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="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dispensariesBySlug&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/listing/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&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="nx"&gt;routes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&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="nx"&gt;data&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="s2"&gt; — Directory`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&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="nx"&gt;data&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="s2"&gt; in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. Rating: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/5.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;buildDetailHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="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;Every matched detail page now has real name, rating, review count, type, address, and description injected at build time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What got submitted to GSC
&lt;/h2&gt;

&lt;p&gt;After the four commits deployed, I submitted blog URLs via GSC's URL Inspection tool and queued the rest for the following day after hitting the daily limit. City and detail pages surface through the sitemap on the next crawl cycle.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────────────┐
│                  ROUTE STATUS AFTER AUDIT                │
├─────────────────────────────────────────────────────────┤
│                                                          │
│  Blog pages              →  correct canonical + body    │
│  Out-of-state listings   →  noindexed                   │
│  Ghost town pages        →  noindexed                   │
│  City pages (29)         →  unique content injected     │
│  Detail pages            →  real DB data injected       │
│                                                          │
│  Catch-all fires only for paths that don't exist        │
└─────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The check that catches it
&lt;/h2&gt;

&lt;p&gt;After any prerender change, before pushing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm run build
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:4173/your-route | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'canonical'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see &lt;code&gt;canonical: /&lt;/code&gt; on a page that isn't the homepage, the catch-all is winning. A route is missing from &lt;code&gt;STATIC_ROUTES&lt;/code&gt; or the prerender write failed silently.&lt;/p&gt;

&lt;p&gt;The other check is in Vercel build logs — the prerender should log a row count for every data fetch. If it logs zero or logs nothing, the database connection failed and detail pages are running on fallback content. Treat zero as a failure, not a warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is easy to miss
&lt;/h2&gt;

&lt;p&gt;The system behaved correctly at every layer. Vite built the bundle. The prerender ran. Vercel deployed green. The site worked perfectly in a browser — React hydrated immediately and the correct content appeared.&lt;/p&gt;

&lt;p&gt;Only a raw HTTP request exposed the problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://example.com/city/denver"&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;canonical
&lt;span class="c"&gt;# &amp;lt;link rel="canonical" href="https://example.com/" /&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Googlebot doesn't execute JavaScript. It reads what the server sends. Everything the human-facing monitoring stack measured was post-hydration. The failure existed entirely in the gap between what the server sent and what the browser rendered — a gap that only matters to crawlers.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>vercel</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>OpenClaw vs Hermes Agent: Similarities, Differences, and Where Each Shines</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Sun, 24 May 2026 02:14:39 +0000</pubDate>
      <link>https://forem.com/henry_dan_81513dd35a2f540/openclaw-vs-hermes-agent-similarities-differences-and-where-each-shines-35lh</link>
      <guid>https://forem.com/henry_dan_81513dd35a2f540/openclaw-vs-hermes-agent-similarities-differences-and-where-each-shines-35lh</guid>
      <description>&lt;p&gt;Both OpenClaw and Hermes Agent are open-source, self-hosted, and connect to Telegram. If you're evaluating which one to run, that surface-level similarity makes the choice look harder than it is. They're built on different philosophies and solve different problems.&lt;/p&gt;

&lt;p&gt;Here's a direct breakdown from someone running both on the same machine.&lt;/p&gt;




&lt;h2&gt;
  
  
  What they have in common
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Open-source and self-hosted — no SaaS dependency&lt;/li&gt;
&lt;li&gt;Connect to Telegram (and other messaging platforms)&lt;/li&gt;
&lt;li&gt;Support multiple LLM providers via OpenRouter, Ollama, Anthropic, and others&lt;/li&gt;
&lt;li&gt;Run as persistent background services (systemd)&lt;/li&gt;
&lt;li&gt;Have memory systems for retaining context&lt;/li&gt;
&lt;li&gt;Support tool use — terminal access, file operations, web browsing&lt;/li&gt;
&lt;li&gt;Can be triggered on demand or on a schedule&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The core difference
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;OpenClaw is gateway-first. Hermes is agent-first.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;OpenClaw is designed around a messaging hub. You connect it to your platforms, configure plugins and models, and it becomes a command center for executing tasks and delivering scheduled outputs. The workflow is defined by you upfront.&lt;/p&gt;

&lt;p&gt;Hermes is designed around an agent that learns. The messaging integration is how you reach it — but the focus is on the agent building knowledge about you over time and using that to work with less steering.&lt;/p&gt;




&lt;h2&gt;
  
  
  Memory: same feature, different approach
&lt;/h2&gt;

&lt;p&gt;Both have memory. The difference is how it works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenClaw&lt;/strong&gt; uses structured, file-based memory — Markdown files, a vector-indexed memory-wiki, and periodic consolidation. You control what gets stored. You can read and edit the files directly. It's transparent and predictable, but requires some manual involvement to stay useful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hermes&lt;/strong&gt; writes memory automatically after every session. Hermes autonomously decides what to persist — your preferences, your stack, your patterns — without you configuring anything. It compounds over time on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where OpenClaw shines
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled automation&lt;/strong&gt; — cron job delivery, morning reports, recurring alerts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-platform routing&lt;/strong&gt; — Telegram, Discord, Slack, WhatsApp from one gateway&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plugin ecosystem&lt;/strong&gt; — mature, community-built skills and integrations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reliability&lt;/strong&gt; — designed to run 24/7 without intervention&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transparency&lt;/strong&gt; — you define exactly what it does and when&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Predictability&lt;/strong&gt; — deterministic workflows mean easier debugging, no operational drift, and explicit control over every boundary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Best for: operators who want dependable, configured automation running on a schedule — and who prefer constrained systems where behavior is always explainable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where Hermes shines
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Persistent memory&lt;/strong&gt; — autonomously builds and maintains context across sessions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduced steering&lt;/strong&gt; — the longer it runs, the less you have to repeat yourself&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autonomous task decomposition&lt;/strong&gt; — give it a vague goal, it figures out the steps&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skill authoring&lt;/strong&gt; — turns successful workflows into reusable, versioned skills&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;System awareness&lt;/strong&gt; — can scan your machine and build a working model of your setup&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Best for: operators who want an agent that gets more useful over time without manual configuration.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where each falls short
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;OpenClaw&lt;/strong&gt; — long-term continuity requires deliberate memory maintenance from the operator. The system is only as aware as what you've explicitly stored. Heavy orchestration workflows also need upfront plugin work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hermes&lt;/strong&gt; — newer, heavier to set up, smaller ecosystem. Free models handle light tasks but will struggle with deep reasoning. Still building the community and skill library that OpenClaw already has.&lt;/p&gt;




&lt;h2&gt;
  
  
  In practice: the same task, two different behaviors
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;OpenClaw&lt;/th&gt;
&lt;th&gt;Hermes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Morning SEO report&lt;/td&gt;
&lt;td&gt;Deterministic cron — runs at 8:30am, delivers the same structured output every day&lt;/td&gt;
&lt;td&gt;Contextual — can flag anomalies, cross-reference previous sessions, surface patterns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telegram command&lt;/td&gt;
&lt;td&gt;Fast execution, plugin-defined scope&lt;/td&gt;
&lt;td&gt;More contextual responses, draws on accumulated session history&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Follow-up 3 days later&lt;/td&gt;
&lt;td&gt;Requires explicit context — you re-explain your setup&lt;/td&gt;
&lt;td&gt;Retains prior context automatically — no re-steering needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New automation task&lt;/td&gt;
&lt;td&gt;You define the plugin/prompt upfront&lt;/td&gt;
&lt;td&gt;Can decompose the goal into steps and build a reusable skill&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System awareness&lt;/td&gt;
&lt;td&gt;Knows what you configured it to know&lt;/td&gt;
&lt;td&gt;Can scan the machine and build its own working model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The verdict
&lt;/h2&gt;

&lt;p&gt;They're not competing for the same job — which means you don't have to choose.&lt;/p&gt;

&lt;p&gt;Run OpenClaw for execution: scheduled scripts, cron delivery, real-time Telegram commands, monitoring. It's the better runtime.&lt;/p&gt;

&lt;p&gt;Run Hermes for intelligence: tasks where context from previous sessions matters, complex planning, anything where you want the agent to reduce how much hand-holding it needs over time.&lt;/p&gt;

&lt;p&gt;The strongest setup is both as a stack: OpenClaw for orchestration, Hermes for adaptive reasoning and continuity.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>selfhosted</category>
      <category>automation</category>
      <category>devops</category>
    </item>
    <item>
      <title>Your Vercel Redirect Is Backwards and Google Is Ignoring Your Site</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Sun, 24 May 2026 02:13:08 +0000</pubDate>
      <link>https://forem.com/henry_dan_81513dd35a2f540/your-vercel-redirect-is-backwards-and-google-is-ignoring-your-site-4ci</link>
      <guid>https://forem.com/henry_dan_81513dd35a2f540/your-vercel-redirect-is-backwards-and-google-is-ignoring-your-site-4ci</guid>
      <description>&lt;p&gt;A week after launching this blog I had one page indexed — the homepage. Every post showed "URL is unknown to Google." No crawl attempts, no impressions, no signal at all.&lt;/p&gt;

&lt;p&gt;The site scored 100 on all Core Web Vitals. Sitemap was submitted to GSC on day one. Content was real. There was no obvious technical failure anywhere.&lt;/p&gt;

&lt;p&gt;The actual cause: Vercel had &lt;code&gt;www.tedagentic.com&lt;/code&gt; set as the primary domain and was redirecting &lt;code&gt;tedagentic.com&lt;/code&gt; to it. My GSC property was &lt;code&gt;tedagentic.com&lt;/code&gt;. That single configuration mismatch broke the entire indexing chain silently.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the misconfiguration looks like
&lt;/h2&gt;

&lt;p&gt;Vercel lets you attach multiple domains to a project. One is primary — it serves the content. The others redirect to it. The default when you add both www and non-www is often to make www the primary.&lt;/p&gt;

&lt;p&gt;In my case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tedagentic.com       → 307 redirect → www.tedagentic.com
www.tedagentic.com   → 200 (primary, serves content)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser doesn't care. You type &lt;code&gt;tedagentic.com&lt;/code&gt;, land on &lt;code&gt;www.tedagentic.com&lt;/code&gt;, read the post. Everything looks fine.&lt;/p&gt;

&lt;p&gt;Google cares.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this kills indexing
&lt;/h2&gt;

&lt;p&gt;The problem compounds across three layers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. GSC property mismatch&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My Google Search Console property was &lt;code&gt;tedagentic.com&lt;/code&gt; (non-www). The site was actually serving from &lt;code&gt;www.tedagentic.com&lt;/code&gt;. GSC tracks coverage per property. Pages Google found at &lt;code&gt;www.&lt;/code&gt; weren't being credited to the non-www property I was monitoring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Sitemap URLs were wrong&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The sitemap is generated dynamically from &lt;code&gt;site.url&lt;/code&gt; in the codebase. That value was set to &lt;code&gt;https://tedagentic.com&lt;/code&gt; — but because the site was redirecting there, Astro was sometimes resolving the build-time base URL to the www version. The live sitemap was serving this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;loc&amp;gt;&lt;/span&gt;https://www.tedagentic.com/posts/how-to-set-up-local-ai-agent/&lt;span class="nt"&gt;&amp;lt;/loc&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Google downloaded the sitemap from &lt;code&gt;tedagentic.com/sitemap.xml&lt;/code&gt;, saw URLs pointing to &lt;code&gt;www.tedagentic.com&lt;/code&gt;, and hit contradictory signals on every entry: the sitemap said www, the canonical said non-www, the GSC property was non-www, and the domain had no trust history to resolve the ambiguity. 47 submitted, 0 indexed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Canonical tags contradicted the sitemap&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The canonical tags in the HTML were correct — pointing to &lt;code&gt;tedagentic.com&lt;/code&gt;. But the sitemap was pointing to &lt;code&gt;www.tedagentic.com&lt;/code&gt;. Google had two signals contradicting each other with no clear authority signal to break the tie. On a new domain with no trust built up, it appeared to just stop.&lt;/p&gt;




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

&lt;p&gt;Run these three checks on any new site before assuming it's a crawl delay:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the redirect chain:&lt;/strong&gt;&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;-sI&lt;/span&gt; https://yourdomain.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"HTTP|location"&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://www.yourdomain.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"HTTP|location"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want &lt;code&gt;yourdomain.com&lt;/code&gt; to return 200 and &lt;code&gt;www.yourdomain.com&lt;/code&gt; to 308 redirect to &lt;code&gt;yourdomain.com&lt;/code&gt; — or the reverse, consistently, as long as it matches your GSC property.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check sitemap URLs match your canonical domain:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://yourdomain.com/sitemap.xml | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;loc&amp;gt;"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every URL in the sitemap should use the same domain as your GSC property. No exceptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check your GSC property:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Go to GSC and confirm whether you added &lt;code&gt;tedagentic.com&lt;/code&gt; or &lt;code&gt;www.tedagentic.com&lt;/code&gt; as the property. Then confirm your sitemap URLs and canonical tags use that exact version. All three must match.&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;Step 1 — Flip the Vercel redirect via API:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-vercel-token"&lt;/span&gt;
&lt;span class="nv"&gt;TEAM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-team-id"&lt;/span&gt;
&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-project-id"&lt;/span&gt;

&lt;span class="c"&gt;# Remove both domains&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; DELETE &lt;span class="s2"&gt;"https://api.vercel.com/v9/projects/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/domains/yourdomain.com?teamId=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TEAM&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

curl &lt;span class="nt"&gt;-X&lt;/span&gt; DELETE &lt;span class="s2"&gt;"https://api.vercel.com/v9/projects/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/domains/www.yourdomain.com?teamId=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TEAM&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Re-add with correct direction&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.vercel.com/v10/projects/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/domains?teamId=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TEAM&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"name":"yourdomain.com"}'&lt;/span&gt;

curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.vercel.com/v10/projects/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PROJECT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/domains?teamId=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TEAM&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"name":"www.yourdomain.com","redirect":"yourdomain.com","redirectStatusCode":308}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2 — Fix the site URL in code and redeploy:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Make sure &lt;code&gt;site.url&lt;/code&gt; (or equivalent) matches exactly, commit, and push. Vercel will rebuild the sitemap with correct URLs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 — Resubmit sitemap and request indexing:&lt;/strong&gt;&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="n"&gt;service&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sitemaps&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;siteUrl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sc-domain:yourdomain.com&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;feedpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://yourdomain.com/sitemap.xml&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then use the URL Inspection API or GSC manually to request indexing on your key posts.&lt;/p&gt;




&lt;h2&gt;
  
  
  What it cost
&lt;/h2&gt;

&lt;p&gt;Roughly 6 days of early crawl and indexing momentum on a new domain. The site looked healthy the entire time — no errors in GSC, no warnings, no failed crawls. The mismatch was invisible until I ran the redirect check and compared it against the GSC property.&lt;/p&gt;

&lt;p&gt;On a new domain, early crawl signals matter more than on an established one. Google is deciding how much trust to extend. A canonical inconsistency during that window is harder to recover from than it would be six months in.&lt;/p&gt;

&lt;p&gt;The fix took ten minutes once diagnosed. The diagnosis took a week because there was no visible error to chase.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick checklist for new sites on Vercel
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Confirm which domain is primary in Vercel (Settings → Domains)&lt;/li&gt;
&lt;li&gt;[ ] Confirm GSC property matches that exact domain (www vs non-www)&lt;/li&gt;
&lt;li&gt;[ ] Confirm sitemap &lt;code&gt;&amp;lt;loc&amp;gt;&lt;/code&gt; URLs use the same domain&lt;/li&gt;
&lt;li&gt;[ ] Confirm canonical tags in HTML use the same domain&lt;/li&gt;
&lt;li&gt;[ ] Verify with &lt;code&gt;curl -sI&lt;/code&gt; that the redirect direction is correct&lt;/li&gt;
&lt;li&gt;[ ] Use 308 (permanent) not 307 (temporary) for the www redirect&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>vercel</category>
      <category>seo</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>Google Was Rewriting My Title. The Cause Was a Single JSX Element.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Fri, 22 May 2026 06:19:02 +0000</pubDate>
      <link>https://forem.com/henry_dan_81513dd35a2f540/google-was-rewriting-my-title-the-cause-was-a-single-jsx-element-3mda</link>
      <guid>https://forem.com/henry_dan_81513dd35a2f540/google-was-rewriting-my-title-the-cause-was-a-single-jsx-element-3mda</guid>
      <description>&lt;p&gt;A page I manage was sitting at position 1 for its primary keyword. Over about three weeks it slid to position 9. When I checked the SERP on mobile vs desktop, the title showing in results was different on each device — and neither matched the title tag in the HTML.&lt;/p&gt;

&lt;p&gt;That's the tell. Google doesn't rewrite titles randomly. It rewrites them when the page gives it a better candidate string than the one in &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;. I had done exactly that, without realising it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two causes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Title tag over the display limit
&lt;/h3&gt;

&lt;p&gt;Google's rendered title in search results is constrained to roughly 600px width — around 55–60 characters depending on the character set. When the &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; tag exceeds that, Google looks for a shorter, cleaner alternative on the page: the H1, a prominent heading, or any large text near the top of the document.&lt;/p&gt;

&lt;p&gt;My title tag was 69 characters. Not catastrophic on its own, but it opened the door.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Subtitle span nested inside the H1
&lt;/h3&gt;

&lt;p&gt;This was the actual cause. The pattern looked like this — examples below are illustrative, but this structure appears on any content page with a heading and a subtitle:&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="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-4xl font-bold"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  Best Coffee Shops in Austin
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-muted-foreground text-2xl"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    2026 Neighborhood Guide – Updated Weekly
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&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;h1&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;To a human reading the rendered page, this looks like a heading with a subtitle underneath it. To Google's parser, it reads the entire &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; content as one string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Best Coffee Shops in Austin 2026 Neighborhood Guide – Updated Weekly
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's longer than intended — and in my case it also contained stale data that had since changed. Google preferred the H1 string, started using it, and because it was being pulled from the DOM rather than the &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; tag, it varied depending on how the page rendered (full JS execution on desktop vs partial on mobile), producing different titles per device.&lt;/p&gt;

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

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

&lt;p&gt;&lt;strong&gt;1. Shorten the title tag to under 60 characters&lt;/strong&gt;&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="c"&gt;&amp;lt;!-- before: 69 chars — over the display limit --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Best Coffee Shops in Austin — 2026 Neighborhood Guide by Area&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- after: under 60 chars --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Best Coffee Shops in Austin 2026 — By Neighborhood&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Move the subtitle out of the H1&lt;/strong&gt;&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="c1"&gt;// before — subtitle inside H1, Google reads as one string&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-4xl font-bold"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  Best Coffee Shops in Austin
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-2xl text-muted-foreground"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    2026 Neighborhood Guide – Updated Weekly
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&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;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// after — separate elements, Google reads H1 cleanly&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-4xl font-bold"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  Best Coffee Shops &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-accent"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;in Austin&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&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;h1&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;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-2xl text-muted-foreground"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  2026 Neighborhood Guide – Updated Weekly
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&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;The &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; is no longer semantically part of the heading. Google now has a much cleaner, more consistent title candidate from the &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; tag and one matching candidate from the &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt;. No competition, no rewriting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GSC showed after
&lt;/h2&gt;

&lt;p&gt;Position movement on the main queries within one week of the fix:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;6-month avg&lt;/th&gt;
&lt;th&gt;7-day post-fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;primary head term&lt;/td&gt;
&lt;td&gt;6.5&lt;/td&gt;
&lt;td&gt;3.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;secondary variant&lt;/td&gt;
&lt;td&gt;6.8&lt;/td&gt;
&lt;td&gt;3.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;branded + modifier&lt;/td&gt;
&lt;td&gt;5.3&lt;/td&gt;
&lt;td&gt;4.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;long-tail variant&lt;/td&gt;
&lt;td&gt;6.4&lt;/td&gt;
&lt;td&gt;4.7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;CTR also moved from 9.9% (6-month baseline) to 11.6% in the first week — the clean title converts better in the result snippet because it's not being truncated or substituted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern to watch for
&lt;/h2&gt;

&lt;p&gt;In React and JSX, it's easy to put display-only text inside a heading element for layout convenience. A &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; with reduced font size inside an &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; looks like a subtitle visually but is semantically part of the heading. Google sees the full concatenated string.&lt;/p&gt;

&lt;p&gt;Any time you have secondary text near a heading — a date, a price, a tagline — check whether it's inside the heading element or adjacent to it. If it's inside, and your title tag is already near the character limit, you're handing Google an alternate title it may decide to use.&lt;/p&gt;

&lt;p&gt;The fix is always the same: move the secondary text to a &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt; outside the heading. One element change. The heading stays visually identical; the semantic structure is now correct.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>react</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The Confidence Gap: How AI Introduces Silent Errors on Production Sites</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Thu, 21 May 2026 04:39:34 +0000</pubDate>
      <link>https://forem.com/henry_dan_81513dd35a2f540/the-confidence-gap-how-ai-introduces-silent-errors-on-production-sites-3e4k</link>
      <guid>https://forem.com/henry_dan_81513dd35a2f540/the-confidence-gap-how-ai-introduces-silent-errors-on-production-sites-3e4k</guid>
      <description>&lt;p&gt;The task looked routine. Update a comparison table on a live page — add a new property, adjust a few rows, push to production. The AI completed it, reported it done, and I moved on.&lt;/p&gt;

&lt;p&gt;Two months later, a third-party analysis flagged an entity conflict: a property was listed in the wrong city. Not the wrong building or the wrong street — the wrong city entirely. One was 100+ miles from where the page said it was. The page had been live, indexed, and receiving organic traffic the whole time.&lt;/p&gt;

&lt;p&gt;The page loaded fine. No build errors. No type errors. No 404s. Nothing indicated anything was wrong.&lt;/p&gt;

&lt;p&gt;That's the confidence gap.&lt;/p&gt;




&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;The AI was tasked with building a property comparison table from scratch. It needed names, locations, prices, and policies for each entry. Some values existed in the database. Others the AI inferred from surrounding page content and previously generated text.&lt;/p&gt;

&lt;p&gt;It didn't label those inferences as guesses. It just wrote them. And then reported the task complete.&lt;/p&gt;

&lt;p&gt;Here's what the error looked like — anonymised, but structurally identical to the real failure:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Correct city&lt;/th&gt;
&lt;th&gt;What the page said&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Property A&lt;/td&gt;
&lt;td&gt;Morrison&lt;/td&gt;
&lt;td&gt;Pueblo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Property B&lt;/td&gt;
&lt;td&gt;Manitou Springs&lt;/td&gt;
&lt;td&gt;Colorado Springs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Property C&lt;/td&gt;
&lt;td&gt;Denver&lt;/td&gt;
&lt;td&gt;Fort Collins&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three properties. Three wrong cities. All live, all indexed, all quietly sending wrong signals to Google's entity model.&lt;/p&gt;

&lt;p&gt;The specific mechanism: one property's city field was inferred from surrounding prose on the page — prose that had itself been written by a previous AI task. The error was already baked in before the table was built. When the AI read that text as context, it accepted it as fact, and propagated it forward into the new table.&lt;/p&gt;

&lt;p&gt;AI wrote it wrong. AI read it back. AI wrote it wrong again.&lt;/p&gt;

&lt;p&gt;That's not a hallucination — it's a feedback loop. And unlike a hallucination, it's internally consistent. Nothing on the page contradicts itself. The error is coherent. That's what makes it invisible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the feedback loop matters
&lt;/h2&gt;

&lt;p&gt;Google's entity model doesn't just read your content — it cross-references it. When a page describes a property as being near a specific landmark, but the location field places it 100 miles away, that's a geographic contradiction. The entity resolver flags it. That's a real SEO signal, and it works against you silently, the same way the error was introduced.&lt;/p&gt;

&lt;p&gt;This is the part that makes the feedback loop worse than a one-time mistake. A single wrong fact might degrade one page. A wrong fact that gets read back and propagated spreads across the site — each new page adding another source of the same contradiction, compounding the entity signal problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why confidence is the actual problem
&lt;/h2&gt;

&lt;p&gt;Most AI failures are obvious. The code doesn't compile. The page crashes. The API returns an error. You see it immediately.&lt;/p&gt;

&lt;p&gt;This failure class is different. The output is syntactically and visually correct. A human skimming the table would not notice unless they already knew the right answer. The AI's tone when reporting completion — "done, pushed" — is identical whether it verified the fact or invented it.&lt;/p&gt;

&lt;p&gt;That's the confidence gap: the signal you'd use to detect a problem (confident completion) is the same signal the AI emits when everything is fine.&lt;/p&gt;

&lt;p&gt;The gap is especially dangerous on commercial sites because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Content is published and indexed fast&lt;/li&gt;
&lt;li&gt;Errors compound across pages — wrong fact in one place gets read back and written into the next&lt;/li&gt;
&lt;li&gt;The business stakes are real — wrong location data affects SEO entity signals, user trust, and affiliate conversions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A developer making this mistake would usually notice when they went to verify the property existed. The AI skips that verification step entirely unless it's explicitly required.&lt;/p&gt;




&lt;h2&gt;
  
  
  The audit
&lt;/h2&gt;

&lt;p&gt;After finding the first error, the right move was a full audit — not just fixing the one instance and moving on.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Query the database directly for source-of-truth values on every property: name, city, address&lt;/li&gt;
&lt;li&gt;Compare each hardcoded reference on each page against the DB record — not against other page content&lt;/li&gt;
&lt;li&gt;Flag discrepancies before touching anything&lt;/li&gt;
&lt;li&gt;Fix only confirmed errors, one at a time, with a pre-push summary table every time&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Across 40 pages audited, the full damage report:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Error type&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wrong city&lt;/td&gt;
&lt;td&gt;3 properties&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong name&lt;/td&gt;
&lt;td&gt;2 properties&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inconsistent rating&lt;/td&gt;
&lt;td&gt;6 instances&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total corrections&lt;/td&gt;
&lt;td&gt;9+&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All of these had been live. None had triggered any automated alert.&lt;/p&gt;

&lt;p&gt;The key discipline: &lt;strong&gt;the database is the source of truth, not the page&lt;/strong&gt;. Auditing a page against itself finds nothing — it just confirms the error is internally consistent. You have to go upstream.&lt;/p&gt;




&lt;h2&gt;
  
  
  The process change
&lt;/h2&gt;

&lt;p&gt;Going forward, the rule is simple: no property detail gets written without a verified source. If the city isn't in the DB record, you ask for it. You don't fill the gap.&lt;/p&gt;

&lt;p&gt;Before every push to a commercial site, a pre-push summary table is required:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ExamplePage.tsx&lt;/td&gt;
&lt;td&gt;location&lt;/td&gt;
&lt;td&gt;Old value&lt;/td&gt;
&lt;td&gt;Verified value&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The human reviews that table before the commit happens. Not after.&lt;/p&gt;

&lt;p&gt;This doesn't eliminate AI mistakes — it makes them visible before they go live. That's the only realistic bar. AI tools will continue to infer values from context when they don't have a verified source. The operator's job is to build a process that catches that gap before it ships.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this means for operators
&lt;/h2&gt;

&lt;p&gt;If you're using AI to build or maintain a commercial site, assume there are errors you haven't found yet. The absence of visible problems is not evidence of accuracy.&lt;/p&gt;

&lt;p&gt;The most dangerous AI outputs are the ones that look right. Code that doesn't compile gets fixed immediately. Content that's factually wrong but grammatically perfect sits in production for months — getting indexed, getting read back, getting propagated.&lt;/p&gt;

&lt;p&gt;Run your audit from the source of truth, not from the page. Build the pre-push review into the workflow before something gets indexed. And treat AI confidence as a neutral signal — it tells you the task completed, not that the output is correct.&lt;/p&gt;

&lt;p&gt;The gap between those two things is where production errors live.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>production</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Vercel Stopped Deploying. No Alert. No Error. Just Old Code.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Wed, 20 May 2026 03:04:56 +0000</pubDate>
      <link>https://forem.com/henry_dan_81513dd35a2f540/vercel-stopped-deploying-no-alert-no-error-just-old-code-4od7</link>
      <guid>https://forem.com/henry_dan_81513dd35a2f540/vercel-stopped-deploying-no-alert-no-error-just-old-code-4od7</guid>
      <description>&lt;p&gt;I pushed a set of changes to a production site — new page sections, updated prerender content, a comparison table entry. Checked the live site an hour later. Nothing had changed.&lt;/p&gt;

&lt;p&gt;Checked Vercel. Project showed healthy. No red deployments, no failure notifications, no emails. The dashboard looked completely normal.&lt;/p&gt;

&lt;p&gt;Checked the Deployments tab. Last deployment: 10 hours ago. Not two minutes ago when I pushed. Ten hours.&lt;/p&gt;

&lt;p&gt;Every commit had gone to GitHub fine — verified with &lt;code&gt;git log&lt;/code&gt;, confirmed the remote had the latest SHA. Vercel simply hadn't picked any of them up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I ruled out first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GitHub webhook disconnected.&lt;/strong&gt; Possible on any project if the GitHub app gets uninstalled or permissions change. But the Vercel project still showed the repo as connected. No indication of a broken webhook in settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root directory misconfigured.&lt;/strong&gt; There was a nested subfolder in the repo that had previously been used as the build root. That subfolder had been deleted. If Vercel was building from that path, every build would fail with a missing directory error. But the Root Directory setting showed &lt;code&gt;./&lt;/code&gt; — the repo root. Ruled out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build command issue.&lt;/strong&gt; Nothing had changed in &lt;code&gt;package.json&lt;/code&gt; or &lt;code&gt;vite.config.ts&lt;/code&gt;. The build had worked before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Forcing a deploy to see the actual error
&lt;/h2&gt;

&lt;p&gt;With the GitHub webhook not triggering, the only way to get a build log was to force a deploy manually. Used the Vercel CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vercel &lt;span class="nt"&gt;--prod&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Immediate error:&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;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;"deploy_failed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Redirect at index 0 cannot define both `permanent` and `statusCode` properties."&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;There it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The config conflict
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;vercel.json&lt;/code&gt;, the first redirect — a www-to-non-www canonical redirect — had been written with both properties:&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;"/(.*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"host"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"www.yourdomain.com"&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;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://yourdomain.com/$1"&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="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statusCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;301&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; and &lt;code&gt;statusCode: 301&lt;/code&gt; are redundant — &lt;code&gt;permanent: true&lt;/code&gt; already means 301. Vercel's config validator rejects any redirect that specifies both. The fix is one line:&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;"/(.*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"has"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"host"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"www.yourdomain.com"&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;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://yourdomain.com/$1"&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;h2&gt;
  
  
  Why it was silent
&lt;/h2&gt;

&lt;p&gt;This is the part worth documenting.&lt;/p&gt;

&lt;p&gt;When a &lt;code&gt;vercel.json&lt;/code&gt; config error fails validation, the GitHub webhook fires, Vercel receives it, validation fails before the build even starts, and the whole thing is discarded quietly. In this case, Vercel surfaced no visible failure notification in the dashboard or by email. The dashboard keeps showing the last successful deployment as if nothing happened.&lt;/p&gt;

&lt;p&gt;The only visible sign is that the "Last deployment" timestamp stops advancing. If you're not actively checking that timestamp, you won't notice.&lt;/p&gt;

&lt;p&gt;The failure was introduced with a commit that added &lt;code&gt;statusCode: 301&lt;/code&gt; to an existing redirect that already had &lt;code&gt;permanent: true&lt;/code&gt;. The intent was clarity. The effect was silently breaking every deployment after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secondary issue: CLI upload was stalling on node_modules
&lt;/h2&gt;

&lt;p&gt;When I first ran &lt;code&gt;vercel --prod&lt;/code&gt; to diagnose, the upload stalled at ~150MB and had to be killed. There was no &lt;code&gt;.vercelignore&lt;/code&gt; in the repo, so the CLI was uploading &lt;code&gt;node_modules&lt;/code&gt; (357MB) along with the source.&lt;/p&gt;

&lt;p&gt;Fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# .vercelignore
&lt;/span&gt;&lt;span class="err"&gt;node_modules&lt;/span&gt;
&lt;span class="err"&gt;dist&lt;/span&gt;
&lt;span class="err"&gt;.git&lt;/span&gt;
&lt;span class="err"&gt;*.log&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place the upload dropped to ~39MB and completed in seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check when Vercel stops auto-deploying
&lt;/h2&gt;

&lt;p&gt;If pushes are going to GitHub but Vercel isn't deploying:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check the Deployments tab timestamp&lt;/strong&gt; — if it stopped advancing, builds are failing before they start&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Force a deploy via CLI&lt;/strong&gt; (&lt;code&gt;vercel --prod&lt;/code&gt;) — the CLI surfaces the actual error immediately, the dashboard won't&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at vercel.json first&lt;/strong&gt; — config validation errors fail silently and are the most common cause of webhook builds being discarded without notification&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for redundant redirect properties&lt;/strong&gt; — &lt;code&gt;permanent&lt;/code&gt; and &lt;code&gt;statusCode&lt;/code&gt; on the same redirect is the specific conflict Vercel rejects&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The GitHub integration kept working. The pushes were fine. The config was wrong. Vercel's silence on the failure is the thing that made it hard to find.&lt;/p&gt;

</description>
      <category>vercel</category>
      <category>webdev</category>
      <category>devops</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Lovable Shipped SSR. Here's What That Actually Changes.</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Wed, 20 May 2026 03:01:39 +0000</pubDate>
      <link>https://forem.com/henry_dan_81513dd35a2f540/lovable-shipped-ssr-heres-what-that-actually-changes-1b0c</link>
      <guid>https://forem.com/henry_dan_81513dd35a2f540/lovable-shipped-ssr-heres-what-that-actually-changes-1b0c</guid>
      <description>&lt;p&gt;Today Lovable's co-founder announced they're shipping SEO as a first-class feature: new apps are now server-side rendered, and existing apps get pre-rendering automatically.&lt;/p&gt;

&lt;p&gt;The timing is notable. I &lt;a href="https://tedagentic.com/posts/prerender-silent-failure" rel="noopener noreferrer"&gt;published a post&lt;/a&gt; about a prerender pipeline I built manually for a Lovable site — one that ran on every deploy, silently failed for six months because of missing environment variables, and left four directory pages with zero GSC impressions the entire time. Lovable just made that class of problem their problem, not yours.&lt;/p&gt;

&lt;p&gt;So: does this change the argument from &lt;a href="https://tedagentic.com/posts/why-astro-over-lovable" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt;?&lt;/p&gt;

&lt;p&gt;The short answer is: partially. It fixes the crawlability problem. It doesn't close the performance gap, and the two paths they've shipped are actually quite different.&lt;/p&gt;

&lt;h2&gt;
  
  
  What they shipped
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;New apps: server-side rendering.&lt;/strong&gt; Every request hits a server, which renders HTML dynamically and sends it to the browser. Googlebot gets real content on the first request — same as SSR frameworks like Next.js in default mode. The CSR crawlability problem is gone for new projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Existing apps: automatic pre-rendering.&lt;/strong&gt; Build-time pre-rendering injects static HTML into the page before the React bundle runs. This is exactly the approach I was doing manually — a script that fetches data at deploy time and writes it into the HTML. Lovable is now doing this automatically.&lt;/p&gt;

&lt;p&gt;These are two different things. Worth naming them separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  What pre-rendering for existing apps actually means
&lt;/h2&gt;

&lt;p&gt;The pre-rendering path for existing apps is a patch on CSR, not a structural fix. The React bundle still ships. The browser still downloads it, parses it, executes it, and hydrates. The pre-rendered HTML is injected at build time — so it's only as fresh as the last deploy.&lt;/p&gt;

&lt;p&gt;What it fixes: Googlebot's first-wave crawl now gets real content instead of an empty shell. That's the thing that was causing pages to drop out of the index.&lt;/p&gt;

&lt;p&gt;What it doesn't fix: the client-side hydration overhead. The performance characteristics are still those of a React CSR app — full JS bundle, hydration on every page load, PageSpeed scores that require active optimization to keep above 80.&lt;/p&gt;

&lt;p&gt;The silent failure mode I documented — where the pre-render ran but the data fetch failed quietly and injected a placeholder — that risk goes away when Lovable controls the pipeline. They can test it. They can fail loudly. They can handle env vars correctly by default. The failure mode I hit was an implementation problem with a DIY script, not an inherent limit of pre-rendering. Lovable's version will be more reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SSR for new apps actually means
&lt;/h2&gt;

&lt;p&gt;Server-side rendering is a real architectural change, not a patch. Every request hits a server, which generates HTML with current data and sends it. Googlebot sees real content. Users see real content on the first byte.&lt;/p&gt;

&lt;p&gt;This puts Lovable new apps in the same rendering category as Next.js with &lt;code&gt;getStaticProps&lt;/code&gt; or a hybrid SSR setup — crawlable, indexable, real HTML on first request.&lt;/p&gt;

&lt;p&gt;The tradeoff SSR carries that SSG doesn't: every request has server latency. A static site serves from a CDN edge node — the file is already built, it just needs to travel from the nearest edge to the user. An SSR app has to compute the response first. For content that doesn't change per-user, that compute step is unnecessary overhead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Static site (SSG/Astro)          SSR (Lovable new apps)
──────────────────────           ──────────────────────
Request → CDN edge               Request → server
           ↓                                ↓
         file served             render HTML (with live data)
         immediately                        ↓
                                      send to browser

TTFB: ~30–80ms (edge)            TTFB: ~200–500ms+ (server)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an app — a tool, a dashboard, something with real interactivity or user-specific data — SSR is the right call. The server compute is justified because the content is actually dynamic.&lt;/p&gt;

&lt;p&gt;For a content blog where every page is the same for every visitor, SSG is still faster. The HTML is already there. No server needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the Astro argument still holds
&lt;/h2&gt;

&lt;p&gt;For &lt;strong&gt;pure content sites&lt;/strong&gt; — blogs, documentation, resource hubs — the case for SSG hasn't changed. Astro ships zero JavaScript by default. Every page is a file on disk served from the CDN edge. There's no server to maintain, no per-request compute cost, and no JS bundle penalty on performance metrics.&lt;/p&gt;

&lt;p&gt;My Astro blog consistently scores 95+ on mobile PageSpeed. A Lovable SSR app will score lower without active optimization — not because SSR is bad, but because it ships a full React bundle on every page and adds server latency to every request.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;content sites that need real interactivity&lt;/strong&gt; — some dynamic widgets, user-facing forms, live data sections alongside static content — Lovable SSR is now a legitimate option where it wasn't before. The crawlability problem is solved. You're trading some performance headroom for faster development.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;existing Lovable sites&lt;/strong&gt; with the CSR problem: the automatic pre-rendering means the indexability issue gets handled without a DIY script. The performance characteristics don't change, but the SEO floor is now reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual update to Part 1
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://tedagentic.com/posts/why-astro-over-lovable" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; said: "Lovable is built for apps, not blogs. But plenty of people use it for content sites anyway — that's the mistake this post is about."&lt;/p&gt;

&lt;p&gt;The update is: Lovable is now credible for content sites that need interactivity. New apps are SSR — crawlable, indexable, real HTML on first request. The mistake that post was about (using a CSR framework for a content site and watching pages disappear from the index) is no longer baked into the platform by default.&lt;/p&gt;

&lt;p&gt;The performance gap between SSR and SSG still exists. For a content site where every millisecond of LCP matters and every page is the same for every visitor, SSG is still the faster path. But the hard wall that made Lovable the wrong choice for organic search is gone for new projects.&lt;/p&gt;

&lt;p&gt;That's a significant shift. The choice is now between a fast-to-ship SSR app with React's full ecosystem versus a leaner SSG site with lower performance overhead. That's a real tradeoff, not a clear wrong answer.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 1: &lt;a href="https://tedagentic.com/posts/why-astro-over-lovable" rel="noopener noreferrer"&gt;Astro vs Lovable for SEO — Why Static Sites Win&lt;/a&gt;&lt;/em&gt;&lt;br&gt;&lt;br&gt;
&lt;em&gt;Part 2: &lt;a href="https://tedagentic.com/posts/claude-built-my-astro-blog" rel="noopener noreferrer"&gt;Building an Astro Blog with Claude Code&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>astro</category>
    </item>
    <item>
      <title>Building an Astro Blog with Claude Code</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Tue, 19 May 2026 15:42:09 +0000</pubDate>
      <link>https://forem.com/henry_dan_81513dd35a2f540/building-an-astro-blog-with-claude-code-4cjb</link>
      <guid>https://forem.com/henry_dan_81513dd35a2f540/building-an-astro-blog-with-claude-code-4cjb</guid>
      <description>&lt;p&gt;In &lt;a href="https://tedagentic.com/posts/why-astro-over-lovable" rel="noopener noreferrer"&gt;part 1&lt;/a&gt; I explained why I moved from Lovable.dev to Astro for SEO. Lovable builds React apps that serve empty HTML to Googlebot — Astro builds static HTML that Google can read instantly, no JavaScript required.&lt;/p&gt;

&lt;p&gt;This post is part 2: actually building the blog. But instead of walking you through every command manually, I handed it to Claude.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prompt
&lt;/h2&gt;

&lt;p&gt;One prompt. That's it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Build me an SEO-ready Astro blog from scratch with Tailwind, MDX, RSS feed, sitemap, and fonts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From that, Claude installed everything, built the full project structure, and had a working blog ready to deploy. Here's exactly what happened — including the decisions it made that I didn't ask for, and where it hit problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Claude Installed
&lt;/h2&gt;

&lt;p&gt;Claude ran the following commands in sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm create astro@latest astro-demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When prompted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Template: &lt;strong&gt;Empty&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;TypeScript: &lt;strong&gt;Yes, strict&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Install dependencies: &lt;strong&gt;Yes&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Initialize git repo: &lt;strong&gt;Yes&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then added the integrations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx astro add tailwind
npx astro add mdx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then fonts and RSS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @fontsource-variable/inter @fontsource/jetbrains-mono
npm &lt;span class="nb"&gt;install&lt;/span&gt; @astrojs/rss
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing it caught that I didn't ask about: it skipped &lt;code&gt;@astrojs/sitemap&lt;/code&gt; entirely. That integration crashes on Astro 4.16+ with a &lt;code&gt;Cannot read properties of undefined (reading 'reduce')&lt;/code&gt; error. Claude knew this and built a manual sitemap endpoint at &lt;code&gt;src/pages/sitemap.xml.ts&lt;/code&gt; instead. That's the kind of thing you only know if you've hit the bug — or in this case, if your agent has enough context to anticipate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Claude Built
&lt;/h2&gt;

&lt;p&gt;The full project structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/
├── components/
│   ├── Header.astro
│   ├── PostList.astro
│   └── SeriesNav.astro
├── content/
│   └── posts/        # .md/.mdx files go here
├── layouts/
│   └── BaseLayout.astro
├── pages/
│   ├── index.astro
│   ├── about.astro
│   ├── archive.astro
│   ├── posts/[slug].astro
│   ├── tags/[tag].astro
│   ├── categories/[category].astro
│   ├── series/[series].astro
│   ├── rss.xml.ts
│   └── sitemap.xml.ts
└── styles/
    └── globals.css
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scope here is worth noting. I asked for a blog. Claude built a full content taxonomy — tags, categories, series navigation, archive page, RSS feed, sitemap. Every route it created serves a purpose for either discoverability or SEO. Tag and category pages in particular matter for topic clustering: they give Google a signal that this site covers a coherent subject area, not just a loose collection of posts, which helps individual posts inherit topical authority over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SEO Decisions Baked Into BaseLayout
&lt;/h2&gt;

&lt;p&gt;This is the part most tutorials skip. The layout file is where most of your SEO either works or doesn't.&lt;/p&gt;

&lt;p&gt;Claude built &lt;code&gt;BaseLayout.astro&lt;/code&gt; with the following automatically wired up for every page:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Canonical URL&lt;/strong&gt; — generated from the site URL and current path. Every page declares its own canonical, which prevents duplicate content issues if Vercel's preview URLs ever get indexed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open Graph tags&lt;/strong&gt; — &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;, &lt;code&gt;og:image&lt;/code&gt;, &lt;code&gt;og:url&lt;/code&gt;, &lt;code&gt;og:type&lt;/code&gt;. These control how the page appears when shared on Twitter, LinkedIn, or Slack. Claude wired them to the same frontmatter fields as the page meta, so there's no separate OG configuration needed per post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Twitter Card&lt;/strong&gt; — &lt;code&gt;summary_large_image&lt;/code&gt; by default, so post shares show a full image preview rather than a small thumbnail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JSON-LD structured data&lt;/strong&gt; — three schemas per post page: &lt;code&gt;Article&lt;/code&gt;, &lt;code&gt;WebSite&lt;/code&gt;, and &lt;code&gt;BreadcrumbList&lt;/code&gt;. These are what Google uses to understand the page type, authorship, and navigation hierarchy. On a CSR site this has to load via JavaScript and might not be seen by crawlers. On Astro it's in the HTML before any JavaScript runs.&lt;/p&gt;

&lt;p&gt;All of this is automatic. Drop a new post file in &lt;code&gt;src/content/posts/&lt;/code&gt;, push, and every one of those meta tags is generated correctly with no manual input.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Content Schema
&lt;/h2&gt;

&lt;p&gt;Claude defined the content schema in &lt;code&gt;src/content/config.ts&lt;/code&gt;:&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="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="nx"&gt;publishDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="nx"&gt;updatedDate&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;guides&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;builds&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="nx"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="nx"&gt;imageAlt&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="nx"&gt;series&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;series&lt;/code&gt; and &lt;code&gt;part&lt;/code&gt; fields were something I asked for specifically — I knew I'd want to group related posts. Claude built &lt;code&gt;SeriesNav.astro&lt;/code&gt; to handle the series UI automatically: previous/next links within a series, plus a link back to the series index page. Posts without &lt;code&gt;series&lt;/code&gt; in their frontmatter render as standalone, no extra configuration needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where It Hit Problems
&lt;/h2&gt;

&lt;p&gt;It wasn't perfect. Two things needed manual fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Vercel deploy config.&lt;/strong&gt; The first deploy failed because Vercel defaulted to the wrong output directory. Claude had to add a &lt;code&gt;vercel.json&lt;/code&gt; file to the root:&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;"buildCommand"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm run build"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"outputDirectory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dist"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"framework"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"astro"&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;After that it deployed cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The GitHub push from the server.&lt;/strong&gt; Claude Code runs on my homeserver, not my local machine. To push to GitHub from the server, the git remote needs a personal access token embedded in the URL — standard HTTPS auth doesn't work in a headless environment. Claude flagged this and set the remote correctly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git remote set-url origin https://TOKEN@github.com/username/repo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One security note: treat this token like a password. Don't commit it, don't leave it in shell history. Either use a fine-grained GitHub token scoped to the single repo, or switch to SSH keys if you're on a machine you control long-term.&lt;/p&gt;

&lt;p&gt;Not a Claude Code limitation — just a server environment reality. Worth knowing if you're running agents on a remote machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Push to GitHub and Deploy
&lt;/h2&gt;

&lt;p&gt;Once the remote was configured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add &lt;span class="nb"&gt;.&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Initial blog build"&lt;/span&gt;
git push origin master
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then on Vercel: Add New Project → import the repo → Deploy. Vercel detects Astro automatically but needs one config file to correctly set the output directory — that's the &lt;code&gt;vercel.json&lt;/code&gt; above. Once it's there, no further build configuration is needed.&lt;/p&gt;

&lt;p&gt;Within two minutes the blog was live at a Vercel URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;A fully working, SEO-ready Astro blog — deployed on Vercel, with tags, categories, series navigation, RSS, sitemap, JSON-LD schema, Open Graph, and canonical URLs — built from a single prompt in one session. The full build ran in just over 7 minutes.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Demo video coming soon.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://astro-demo-theta-tawny.vercel.app" rel="noopener noreferrer"&gt;astro-demo-theta-tawny.vercel.app&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Actually Demonstrates
&lt;/h2&gt;

&lt;p&gt;The point isn't that Claude wrote the code. It's that agentic workflows collapse the gap between a decision and a working system.&lt;/p&gt;

&lt;p&gt;The traditional path for this project: read the Astro docs, pick a template, configure Tailwind, wire up MDX, figure out why &lt;code&gt;@astrojs/sitemap&lt;/code&gt; crashes, build the content schema, write the base layout, set up JSON-LD manually, troubleshoot the Vercel config, push. Half a day minimum, probably longer.&lt;/p&gt;

&lt;p&gt;The agentic path: one prompt, review the output, fix two issues, ship.&lt;/p&gt;

&lt;p&gt;The output is the same. The time investment is not.&lt;/p&gt;

&lt;p&gt;That's what this blog documents — not AI as a novelty, but AI as operational infrastructure. The blog itself was built the same way the systems it writes about are built: give the agent a clear goal, stay in the loop on decisions that matter, and let it handle the implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the SEO Foundation Matters More Than the Framework
&lt;/h2&gt;

&lt;p&gt;A lot of people building blogs with AI tools focus on the wrong thing. They want the blog to look good, deploy fast, and be easy to write in. Those matter. But the SEO foundation — the stuff that determines whether Google can read and understand your content — is decided before you write a single post.&lt;/p&gt;

&lt;p&gt;Here's what this build gets right by default:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every page is crawlable on the first request.&lt;/strong&gt; No JavaScript rendering required. Googlebot hits the URL, gets complete HTML, indexes it. That's it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured data is present and correct on every post.&lt;/strong&gt; The JSON-LD schema Claude wired into &lt;code&gt;BaseLayout.astro&lt;/code&gt; tells Google this is an Article, who authored it, when it was published, and where it sits in the site hierarchy. This is what enables rich results in SERPs — date stamps, breadcrumbs, author attribution. On a manually-built blog these are easy to get wrong or forget entirely. Here they're generated automatically from frontmatter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The sitemap updates on every deploy.&lt;/strong&gt; The custom &lt;code&gt;sitemap.xml.ts&lt;/code&gt; endpoint queries the content collection and generates a fresh sitemap at build time. Every new post is automatically included with its correct URL and publish date. You never manually update a sitemap or worry about a new post being missed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RSS keeps returning readers engaged.&lt;/strong&gt; The RSS feed at &lt;code&gt;/rss.xml&lt;/code&gt; is a low-effort retention mechanism. Readers who subscribe get notified of new posts without you needing to send an email or post on social. For a technical blog, a meaningful chunk of the audience prefers RSS — it's worth having from day one.&lt;/p&gt;

&lt;p&gt;None of this required separate configuration. It was all handled in the initial build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write Your First Post
&lt;/h2&gt;

&lt;p&gt;Drop a &lt;code&gt;.md&lt;/code&gt; file in &lt;code&gt;src/content/posts/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;First&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Post"&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;short&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Google."&lt;/span&gt;
&lt;span class="na"&gt;publishDate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-04-27"&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;astro"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;seo"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;guides"&lt;/span&gt;
&lt;span class="na"&gt;draft&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

Your content here.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push to GitHub. Vercel redeploys automatically. Done.&lt;/p&gt;




&lt;p&gt;The build is the easy part. The harder question is what you do next — how you monitor whether Google is actually finding and indexing your posts, and what to do when something goes wrong. That's what the rest of this series covers. Browse all posts in the &lt;a href="https://tedagentic.com/series/astro-seo-blog" rel="noopener noreferrer"&gt;Astro SEO Blog&lt;/a&gt; series.&lt;/p&gt;

</description>
      <category>astro</category>
      <category>claudeai</category>
      <category>webdev</category>
      <category>seo</category>
    </item>
    <item>
      <title>Astro vs Lovable for SEO: Why Static Sites Win</title>
      <dc:creator>Ted</dc:creator>
      <pubDate>Tue, 19 May 2026 15:18:29 +0000</pubDate>
      <link>https://forem.com/henry_dan_81513dd35a2f540/astro-vs-lovable-for-seo-why-static-sites-win-3kgn</link>
      <guid>https://forem.com/henry_dan_81513dd35a2f540/astro-vs-lovable-for-seo-why-static-sites-win-3kgn</guid>
      <description>&lt;p&gt;My monitoring script fired at 3am. A content site I manage — the top-ranking page on that domain, the one its entire internal linking structure pointed to — had dropped out of the Google index entirely.&lt;/p&gt;

&lt;p&gt;Not penalized. Not a manual action. Just gone — as if it never existed.&lt;/p&gt;

&lt;p&gt;The diagnosis was quick. The site ran on a CSR stack — React, client-side rendered. Googlebot had visited, got a near-empty HTML shell, and moved on before JavaScript had a chance to build the DOM. At some point Google stopped waiting and dropped the page entirely.&lt;/p&gt;

&lt;p&gt;The fix took less than an hour. Within a day or two it was back — and when it reappeared, it had moved from position 3 to position 2. Fixing the rendering issue properly actually moved it up.&lt;/p&gt;

&lt;p&gt;That's the thing about CSR: it doesn't just risk a penalty. It quietly suppresses pages that should be ranking, and you won't know until they disappear.&lt;/p&gt;

&lt;p&gt;The fix was a custom prerender layer — a static HTML snippet injected directly into the React root div under the id &lt;code&gt;ssr-prerender&lt;/code&gt;. Googlebot sees real content immediately. Real users get the React app as normal, which replaces it on load. It works. But it's a patch, not a solution — something you bolt on after the fact, maintain separately, and have to remember every time you add a new page or change content.&lt;/p&gt;

&lt;p&gt;Client-side rendering means the server returns an empty HTML shell. The browser runs JavaScript to build the page. That works for users. It's unreliable for crawlers — and the cost doesn't show up immediately. It shows up weeks later when pages quietly stop ranking.&lt;/p&gt;

&lt;p&gt;Lovable is built for apps, not blogs. But plenty of people use it for content sites anyway — that's the mistake this post is about.&lt;/p&gt;

&lt;p&gt;When I decided to build this blog — a place to document agentic AI workflows that I actually want people to find — I wasn't going to patch my way through it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tools I Looked At
&lt;/h2&gt;

&lt;p&gt;Before landing on a stack, I mapped out the real options. Not just "what's popular" but what the actual tradeoffs are for a content site in 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lovable
&lt;/h3&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.amazonaws.com%2Fuploads%2Farticles%2Fnpaax5lsejt5d7cafl8l.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.amazonaws.com%2Fuploads%2Farticles%2Fnpaax5lsejt5d7cafl8l.png" alt="Lovable.dev interface showing a prompt-to-app builder" width="800" height="386"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Lovable is a prompt-to-app builder. You describe what you want, it generates a React codebase, you deploy. The UX is genuinely impressive for web apps — forms, dashboards, interactive tools.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update (May 2026):&lt;/strong&gt; Lovable shipped SSR as a first-class feature. New apps are now server-side rendered — Googlebot gets real HTML on the first request, the crawlability problem is gone for new projects. Existing apps get automatic pre-rendering at build time, which fixes the indexability issue without a DIY script.&lt;/p&gt;

&lt;p&gt;The performance gap with SSG still exists. New Lovable apps ship a full React bundle and add server latency to every request — a static site serves pre-built HTML from a CDN edge node with no per-request compute. For a pure content blog where every page is the same for every visitor and LCP matters, SSG is still the faster path. But the hard wall that made Lovable the wrong choice for organic search is no longer there for new projects.&lt;/p&gt;

&lt;p&gt;I still use it for clients who need web apps or interactive content. For a pure content site with no dynamic requirements, the SSG argument below still holds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cursor
&lt;/h3&gt;

&lt;p&gt;Cursor is a VSCode fork with an embedded AI assistant — excellent for developers who want faster coding without giving up control. It doesn't pick a framework or manage deployments, so the SEO tradeoffs are entirely yours to manage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Actually Use
&lt;/h2&gt;

&lt;p&gt;My setup is deliberately boring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ThinkCentre PC, bare metal Ubuntu, no cloud VM&lt;/li&gt;
&lt;li&gt;Claude Code over SSH as the agent&lt;/li&gt;
&lt;li&gt;Astro 4 — SSG, zero client JS by default&lt;/li&gt;
&lt;li&gt;Vercel for deploy, auto-deploys on every push to master&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Claude Code handles file editing, refactoring, and multi-step builds. I review diffs, approve writes, and push. The agent runs in my terminal — no subscription to a hosted IDE, no vendor controlling my environment.&lt;/p&gt;

&lt;p&gt;Astro was the obvious framework choice once I ruled out React-based options. Every page is static HTML at build time. No JavaScript ships to the browser unless you explicitly add it. Googlebot sees exactly what a user sees, because there's no rendering step to skip.&lt;/p&gt;

&lt;p&gt;The content schema is straightforward MDX files in &lt;code&gt;src/content/posts/&lt;/code&gt;. Writing a new post is dropping a Markdown file and pushing. No CMS, no API calls, no paid tier. The overhead is real but front-loaded — you configure image optimization, redirects, and the content schema once. After that it doesn't move. That's a different kind of maintenance than patching a prerender layer every time you add a page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F802lo9xzki7t3xkowdx2.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.amazonaws.com%2Fuploads%2Farticles%2F802lo9xzki7t3xkowdx2.png" alt="Vercel deploy error during blog setup" width="800" height="253"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Astro vs Lovable for SEO: The Real Comparison
&lt;/h2&gt;

&lt;p&gt;This is the question that matters if you're using AI-assisted tools to build content sites.&lt;/p&gt;

&lt;p&gt;Lovable is built for speed — you describe a UI, it ships React. That's genuinely useful for products, dashboards, anything interactive. But for a content site where organic search is the point, React's default rendering model is the wrong starting point.&lt;/p&gt;

&lt;p&gt;Googlebot has a two-wave crawl model. First wave: fetch the raw HTML. Second wave: render JavaScript. The second wave happens on a delay — sometimes hours, sometimes days — and isn't guaranteed for every page on every crawl. For a new site or a page with low crawl budget, Google may only ever see the first wave. On a CSR site, that first wave is an empty shell.&lt;/p&gt;

&lt;p&gt;Astro doesn't have this problem. The HTML is complete at build time — every page is just a file on disk, served directly. Googlebot gets the same document a user gets, on the first request, every time. An Astro page can be indexed the same day it's published. A Lovable page might take two weeks and a manual fetch request in Search Console to force the second render wave.&lt;/p&gt;

&lt;p&gt;For a blog where every post is a potential traffic source, that's not a minor difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SEO Tradeoff That Actually Matters
&lt;/h2&gt;

&lt;p&gt;Here's the thing nobody says clearly: &lt;strong&gt;your platform choice is an SEO decision.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every framework has a default rendering strategy. If that default is client-side, you're opting into a crawl risk. You can mitigate it, but you can't fully eliminate it — and every mitigation adds operational overhead.&lt;/p&gt;

&lt;p&gt;To be precise: the risk is probabilistic, not guaranteed. High-authority domains with strong crawl frequency and deep internal linking can rank fine on CSR — Google's renderer has improved significantly, and a well-established site gives Googlebot reason to come back for the second wave. The risk is highest where the stakes are also highest: new sites, low crawl budget pages, and content that needs to index fast to catch a trend or a seasonal window. That's exactly where most content blogs live.&lt;/p&gt;

&lt;p&gt;Static site generators like Astro, Eleventy, and Hugo don't have this problem. The HTML is there at request time, before any crawler or user arrives. There's nothing to render, nothing to wait for, nothing to patch around.&lt;/p&gt;

&lt;p&gt;Beyond crawlability, static sites have compounding SEO advantages:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Speed.&lt;/strong&gt; Astro ships zero JavaScript by default — the browser gets a finished HTML file and paints immediately. On a React CSR site, the browser downloads a JS bundle, parses it, executes it, then renders. That sequence adds hundreds of milliseconds to LCP on a cold load, and LCP is a Core Web Vitals signal Google uses as a ranking factor. My Astro blog consistently scores 95+ on PageSpeed Insights mobile. My client sites on CSR stacks rarely break 70 without active optimization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured data.&lt;/strong&gt; In Astro, JSON-LD schema lives in the base layout — defined once, present on every page at build time, no runtime dependency. On a CSR site, your structured data is injected by JavaScript. If the script errors, is blocked, or executes after Googlebot's render window closes, your schema is invisible. You won't see this failure in GSC until it's already cost you rich results.&lt;/p&gt;

&lt;p&gt;Here's what that looks like in practice — the relevant slice of an Astro base layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
const { title, description, canonicalURL } = Astro.props;
const schema = {
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": title,
  "description": description,
  "url": canonicalURL,
};
---
&amp;lt;head&amp;gt;
  &amp;lt;link rel="canonical" href={canonicalURL} /&amp;gt;
  &amp;lt;script type="application/ld+json" set:html={JSON.stringify(schema)} /&amp;gt;
&amp;lt;/head&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The canonical and structured data are rendered into the HTML at build time — no hydration, no runtime dependency, no silent failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Canonical URLs.&lt;/strong&gt; Static generation makes canonicals deterministic. Every page has one URL, set at build time, baked into the HTML. CSR frameworks with dynamic routing can silently generate duplicate URLs under different query parameters — &lt;code&gt;/posts/my-article&lt;/code&gt; and &lt;code&gt;/posts/my-article?ref=homepage&lt;/code&gt; both return 200s, both get crawled, and both dilute the canonical signal unless you've explicitly handled it. With Astro, this class of problem doesn't exist.&lt;/p&gt;

&lt;p&gt;For a content blog that needs to rank, none of this is optional. It's the baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Middle Path
&lt;/h2&gt;

&lt;p&gt;Astro isn't the only way out of CSR. It's worth naming the alternatives so this doesn't read as "Astro or nothing."&lt;/p&gt;

&lt;p&gt;Next.js gives you two paths to HTML-first rendering. &lt;code&gt;output: 'export'&lt;/code&gt; is a full static export — no server features, no API routes, no ISR, just HTML files on disk with the same crawlability as Astro. &lt;code&gt;getStaticProps&lt;/code&gt; is SSG inside a hybrid Next.js app that still has server routes, API endpoints, and ISR — more flexible, but you're choosing the rendering strategy per page deliberately rather than accepting a default. If you're already comfortable in Next.js, either path works. Astro islands (partial hydration) let you drop interactive React or Svelte components into otherwise static pages without shipping a full JS bundle. For a content site that needs one or two dynamic widgets, that's a clean solution.&lt;/p&gt;

&lt;p&gt;The principle is the same regardless of which tool you pick: &lt;strong&gt;HTML-first by default, JavaScript added deliberately.&lt;/strong&gt; Astro makes that the default. Next.js makes it possible but requires you to choose it. Lovable doesn't give you the option.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Tell Someone Starting Now
&lt;/h2&gt;

&lt;p&gt;If you're building a web app — a tool, a dashboard, something with real interactivity — Lovable is a legitimate choice. Ship fast, iterate, worry about SEO when the product is solid.&lt;/p&gt;

&lt;p&gt;If you're building a content site — a blog, a documentation site, a resource hub where organic traffic is the point — don't use a CSR framework as your foundation. The crawl risk is real, the fixes are patches, and you'll spend time maintaining workarounds that a static site simply doesn't need.&lt;/p&gt;

&lt;p&gt;One honest limit worth naming: pure SSG pre-builds every page identically at deploy time. That works perfectly for content. It gets awkward when every page needs to render differently per user — a fully personalised feed, for example. For that you switch Astro to &lt;code&gt;output: 'hybrid'&lt;/code&gt; or &lt;code&gt;output: 'server'&lt;/code&gt; mode, which enables server-side rendering on specific routes while keeping everything else static. Auth and Supabase integration work fine in Astro either way — login flows, protected routes via middleware, user dashboards as islands. The constraint isn't "you can't do auth," it's "fully personalised page-level content at scale wants SSR, not SSG."&lt;/p&gt;

&lt;p&gt;Astro is free, deploys to Vercel in one click, and handles all the SEO fundamentals out of the box. The learning curve is light if you know HTML and basic JavaScript. It's not the only answer — but for a new content site where every page needs to index fast, it's the lowest-friction path to getting that right.&lt;/p&gt;

&lt;p&gt;Read &lt;a href="https://tedagentic.com/posts/claude-built-my-astro-blog" rel="noopener noreferrer"&gt;part 2&lt;/a&gt; to see how I built this blog in a single Claude Code session.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Part 3 covers the Lovable SSR update in detail — what pre-rendering for existing apps actually fixes, what it doesn't, and when the Astro argument still holds: &lt;a href="https://tedagentic.com/posts/lovable-ssr-update" rel="noopener noreferrer"&gt;Lovable Shipped SSR. Here's What That Actually Changes.&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>astro</category>
      <category>seo</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
