<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: GasPriceCheck</title>
    <description>The latest articles on DEV Community by GasPriceCheck (@gaspricecheck).</description>
    <link>https://dev.to/gaspricecheck</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%2F3905345%2Fc58e9d1e-13fa-471c-8325-0448bbd7e28f.png</url>
      <title>DEV Community: GasPriceCheck</title>
      <link>https://dev.to/gaspricecheck</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gaspricecheck"/>
    <language>en</language>
    <item>
      <title>I built a Node script that turns Google Search Console into a daily dashboard</title>
      <dc:creator>GasPriceCheck</dc:creator>
      <pubDate>Thu, 28 May 2026 05:00:00 +0000</pubDate>
      <link>https://dev.to/gaspricecheck/i-built-a-node-script-that-turns-google-search-console-into-a-daily-dashboard-dpo</link>
      <guid>https://dev.to/gaspricecheck/i-built-a-node-script-that-turns-google-search-console-into-a-daily-dashboard-dpo</guid>
      <description>&lt;p&gt;GSC's web UI is fine for occasional spelunking. It's slow for daily ops. Three pages of clicks to get to the same five queries I run every morning. The export is CSV. The date range picker is finicky.&lt;/p&gt;

&lt;p&gt;So I built a Node script. About 150 lines. Three flags. Pulls everything I look at daily into the terminal in four seconds.&lt;/p&gt;

&lt;p&gt;Here's what it does, what it doesn't do, and what I learned about the GSC API along the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check every morning
&lt;/h2&gt;

&lt;p&gt;Five things, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Total clicks last 7 days vs prior 7 days&lt;/li&gt;
&lt;li&gt;Top 25 queries by clicks&lt;/li&gt;
&lt;li&gt;Top 25 pages by clicks&lt;/li&gt;
&lt;li&gt;Pages with the biggest position gain (rising stars)&lt;/li&gt;
&lt;li&gt;Pages with the biggest position loss (warning signs)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In GSC's web UI, that's five-plus clicks and a date range adjustment per check. In a terminal, it's &lt;code&gt;node gsc-query.js --all&lt;/code&gt; and four seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  One-time setup
&lt;/h2&gt;

&lt;p&gt;You need OAuth. The GSC API requires a refresh token, which you get by going through a one-time auth flow.&lt;/p&gt;

&lt;p&gt;I wrote a separate script for this (&lt;code&gt;gsc-auth.js&lt;/code&gt;) that handles the dance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;google&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;googleapis&lt;/span&gt;&lt;span class="dl"&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;open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;open&lt;/span&gt;&lt;span class="dl"&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;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http&lt;/span&gt;&lt;span class="dl"&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;url&lt;/span&gt;&lt;span class="dl"&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;oauth2Client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;google&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OAuth2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GSC_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GSC_CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:3737/oauth-callback&lt;/span&gt;&lt;span class="dl"&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;authUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;oauth2Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateAuthUrl&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;access_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&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;https://www.googleapis.com/auth/webmasters.readonly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Opening browser for authentication...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;oauth2Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Add this to your .env:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GSC_REFRESH_TOKEN=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refresh_token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Auth complete. You can close this tab.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3737&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You run it once, click through the consent screen, and it prints your refresh token to the terminal. Save that to &lt;code&gt;.env&lt;/code&gt; and you never run this script again.&lt;/p&gt;

&lt;p&gt;A note on the OAuth flow: you'll need to set up an OAuth consent screen and a Web Application credential in the Google Cloud Console first. Add &lt;code&gt;http://localhost:3737/oauth-callback&lt;/code&gt; as an authorized redirect URI. The whole setup takes about 10 minutes if you've never done it before, and it's the most annoying part of the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "domain property vs URL prefix" gotcha
&lt;/h2&gt;

&lt;p&gt;This one cost me 30 minutes when I was setting up. Sharing it so it costs you zero.&lt;/p&gt;

&lt;p&gt;GSC has two ways to verify a property:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;URL prefix&lt;/strong&gt; (e.g., &lt;code&gt;https://www.gas-price-check.com/&lt;/code&gt;): covers exactly that protocol + subdomain combo&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain property&lt;/strong&gt; (e.g., &lt;code&gt;gas-price-check.com&lt;/code&gt;): covers all subdomains and protocols&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The API treats these as different objects. To call data for a domain property, you use the format &lt;code&gt;sc-domain:gas-price-check.com&lt;/code&gt;. Without the prefix, the API returns "site not found" with no hint about the format issue.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;siteUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sc-domain:gas-price-check.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// domain property&lt;/span&gt;
&lt;span class="c1"&gt;// not 'https://www.gas-price-check.com'&lt;/span&gt;
&lt;span class="c1"&gt;// not 'gas-price-check.com'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;searchconsole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchanalytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;requestBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-04-22&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-04-29&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dimensions&lt;/span&gt;&lt;span class="p"&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;query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;rowLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&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;If you set up your GSC property as a URL prefix, use the URL with the protocol. If you set up as a domain property, use the &lt;code&gt;sc-domain:&lt;/code&gt; prefix. The error message gives you no hint about which format the API expects, so just remember the rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query script
&lt;/h2&gt;

&lt;p&gt;The actual &lt;code&gt;gsc-query.js&lt;/code&gt; is straightforward. The interesting part is the flag handling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&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;days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--days&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&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;7&lt;/span&gt;&lt;span class="dl"&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;showQueries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--queries&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--all&lt;/span&gt;&lt;span class="dl"&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;showPages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--pages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--all&lt;/span&gt;&lt;span class="dl"&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;showRising&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--rising&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual API call is shaped like this:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;days&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&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;endDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&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;startDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;days&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;searchconsole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchanalytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GSC_SITE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;requestBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;T&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;T&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="nx"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;rowLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&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;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&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;rows&lt;/span&gt; &lt;span class="o"&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;To get top queries: &lt;code&gt;dimensions: ['query']&lt;/code&gt;. Top pages: &lt;code&gt;dimensions: ['page']&lt;/code&gt;. To compute position changes over time: query twice with different date ranges and diff the results.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five queries I run every morning
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node gsc-query.js &lt;span class="nt"&gt;--days&lt;/span&gt; 7              &lt;span class="c"&gt;# summary&lt;/span&gt;
node gsc-query.js &lt;span class="nt"&gt;--queries&lt;/span&gt; &lt;span class="nt"&gt;--days&lt;/span&gt; 28   &lt;span class="c"&gt;# top queries last month&lt;/span&gt;
node gsc-query.js &lt;span class="nt"&gt;--pages&lt;/span&gt; &lt;span class="nt"&gt;--days&lt;/span&gt; 7      &lt;span class="c"&gt;# top pages last week&lt;/span&gt;
node gsc-query.js &lt;span class="nt"&gt;--rising&lt;/span&gt;              &lt;span class="c"&gt;# biggest position gains&lt;/span&gt;
node gsc-query.js &lt;span class="nt"&gt;--falling&lt;/span&gt;             &lt;span class="c"&gt;# biggest position losses&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Top 25 queries (last 28 days):
cheap gas near me                   142 clicks  3,201 impr  4.4% CTR  pos 8.2
gas prices houston                   87 clicks  1,892 impr  4.6% CTR  pos 6.1
77386 gas prices                     61 clicks    520 impr 11.7% CTR  pos 2.8
cheapest gas in austin               52 clicks  1,104 impr  4.7% CTR  pos 9.4
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whole thing returns in four seconds. I run it while my coffee is brewing.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few things I learned about the API
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The free quota is generous.&lt;/strong&gt; GSC API gives you 1,200 queries per minute and 30,000 per day. For a personal dashboard you'll never hit it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The API returns the same data as the UI, but with no rounding.&lt;/strong&gt; The web UI rounds clicks to the nearest digit and aggregates queries below 10 impressions into "..." rows. The API gives you actual numbers, including the long tail of queries with 1-3 impressions. For finding "rising star" queries, that long tail is gold. A query with 4 impressions today that had 0 impressions last week is a signal the web UI hides from you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting is per project, not per user.&lt;/strong&gt; If you have multiple scripts hitting GSC, run them all from the same OAuth project to share the budget cleanly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caching helps a lot.&lt;/strong&gt; I cache yesterday's data in a local JSON file and only re-fetch today's data on the second run of the day. Cuts run time roughly in half.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;searchAppearance&lt;/code&gt; dimension is underrated.&lt;/strong&gt; Filter by &lt;code&gt;searchAppearance: 'AMP_TOP_STORIES'&lt;/code&gt; or &lt;code&gt;'WEBLITE'&lt;/code&gt; to see how each rich-result type is performing separately. Most people never look at this.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I haven't bothered to add
&lt;/h2&gt;

&lt;p&gt;The script is about 150 lines. I've thought about adding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A diff mode that flags queries that moved more than 5 positions week-over-week&lt;/li&gt;
&lt;li&gt;An export-to-CSV flag for when I want to load data into a spreadsheet&lt;/li&gt;
&lt;li&gt;A "branded queries only" filter&lt;/li&gt;
&lt;li&gt;A daily Slack notifier so I don't have to run the script at all&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is a 30-line addition. The fact that I haven't bothered is a sign the script does enough already. Premature feature creep is a real risk on personal tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the point
&lt;/h2&gt;

&lt;p&gt;For a side project, GSC's web UI is fine. For a project where you check the same data daily, building a 150-line wrapper script will save you hours per month and surface signals the web UI hides. The whole thing took me about three hours to build, including the OAuth dance. It's paid that back in saved clicks within a week.&lt;/p&gt;

&lt;p&gt;If you've built a similar GSC daily dashboard, what flags do you reach for? I'm curious what other people's morning checks look like. Mine is pretty narrow, but it's stable, and I've stopped opening the GSC web UI for daily ops entirely. Once a week I check the URL Inspection tool for specific pages in trouble. The rest is the script.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>javascript</category>
      <category>node</category>
      <category>googlecloud</category>
    </item>
    <item>
      <title>Templating got me to 33,620 pages. Indexing them was the hard part.</title>
      <dc:creator>GasPriceCheck</dc:creator>
      <pubDate>Thu, 21 May 2026 05:00:00 +0000</pubDate>
      <link>https://dev.to/gaspricecheck/templating-got-me-to-33620-pages-indexing-them-was-the-hard-part-kem</link>
      <guid>https://dev.to/gaspricecheck/templating-got-me-to-33620-pages-indexing-them-was-the-hard-part-kem</guid>
      <description>&lt;p&gt;I had a fantasy.&lt;/p&gt;

&lt;p&gt;The fantasy was that programmatic SEO would let me skip the part where you write 200 individual articles and trick Google into ranking them. I had a structured dataset (US ZIP codes, cities, states) and a templated page generator. So I shipped 33,620 pages overnight. Then I sat back and waited for the long-tail traffic to roll in.&lt;/p&gt;

&lt;p&gt;That's not what happened.&lt;/p&gt;

&lt;p&gt;A few months later, I have a more nuanced view. Here are the five lessons I would have paid actual money for at the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, so we're working from data
&lt;/h2&gt;

&lt;p&gt;Out of 33,620 templated pages on my site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~6,200 are indexed&lt;/li&gt;
&lt;li&gt;~18,000 are in "Crawled, currently not indexed" (CCNI)&lt;/li&gt;
&lt;li&gt;~8,500 are "Discovered, currently not indexed"&lt;/li&gt;
&lt;li&gt;~700 are flagged as soft 404&lt;/li&gt;
&lt;li&gt;The rest are recently submitted and still in queue&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's an indexation rate of about 18%. Sounds low. It kind of is. But here's the catch: the 6,200 indexed pages drive the vast majority of organic traffic. The CCNI pile is mostly low-traffic ZIPs in low-population areas. Google has decided those aren't worth indexing, and honestly they're probably right.&lt;/p&gt;

&lt;p&gt;If you came into programmatic SEO expecting "ship N pages, get N pages of traffic," recalibrate. The real math is closer to "ship N pages, get 15-25% indexed, and the indexed pile drives 95% of your traffic."&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 1: Templating gives you the structure, not the content
&lt;/h2&gt;

&lt;p&gt;This is the biggest one and the one most programmatic SEO tutorials skip.&lt;/p&gt;

&lt;p&gt;My page generator spit out pages with this rough structure:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Hero (city, state, ZIP)&lt;/li&gt;
&lt;li&gt;Search box&lt;/li&gt;
&lt;li&gt;List of nearby gas stations&lt;/li&gt;
&lt;li&gt;State average price comparison&lt;/li&gt;
&lt;li&gt;"How to save on gas in {city}" section&lt;/li&gt;
&lt;li&gt;Footer&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Sounds reasonable. The problem: when I put two pages side by side (one for ZIPs in California, one for ZIPs in Maine), they were 95% identical text. The hero changed. The station list was different. Everything else was the same paragraph with one word swapped.&lt;/p&gt;

&lt;p&gt;Google's templated-content detection is real and it's specifically looking for this. There's a metric (referenced in their public ranking patents as "boilerplate ratio") that measures what fraction of a page is shared with other pages on the same site. High boilerplate ratio plus thin unique content equals a soft 404 verdict.&lt;/p&gt;

&lt;p&gt;The fix was to make the templated content actually vary. I built a state-context module with hand-written paragraphs for the 15 highest-traffic states. Each is 80 to 120 words covering the state's gas tax, refinery capacity, regulatory regime, and seasonal pricing patterns. The other 35 states got a parameterized template with state-specific variables (avg price, neighboring states, gas tax rate). After this shipped, indexation rates climbed about 15% over six weeks.&lt;/p&gt;

&lt;p&gt;The 80/20 here: hand-writing the top 15 of 50 states covered about 75% of my traffic. The other 35 got the templated treatment, which is fine because they're not driving meaningful traffic anyway.&lt;/p&gt;

&lt;p&gt;If I'd known this at the start, I would have spent the first weekend writing 15 paragraphs by hand and the second weekend generating the rest. Instead I spent four weekends generating everything and then went back to hand-write the top tier. Same end state, slower path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 2: Internal URL types compete with each other
&lt;/h2&gt;

&lt;p&gt;I had two URL types covering the same entity. A programmatic city page (&lt;code&gt;/houston-tx&lt;/code&gt;) and a blog post about Houston gas prices (&lt;code&gt;/blog/cheapest-gas-houston&lt;/code&gt;). Different formats, different content, both about Houston gas.&lt;/p&gt;

&lt;p&gt;Google saw these as competitors. It picked one to index (the blog post) and put the other in CCNI. Then it picked the other for some sister cities (city page won, blog post in CCNI). The pattern was inconsistent and frustrating.&lt;/p&gt;

&lt;p&gt;My first instinct was wrong. I tried to "fix" this by submitting the CCNI page for re-validation, hoping Google would just index both. That's not how this works. Google has a per-site indexation budget and a canonical-decision system that picks one URL per topic per intent. When you ask it to re-evaluate, it just makes the same decision again.&lt;/p&gt;

&lt;p&gt;The actual fix was to differentiate the intent of each URL type:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Blog post:&lt;/strong&gt; opinionated guide, current events, "why are gas prices high in Houston this month"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;City page:&lt;/strong&gt; factual reference, station list, ZIP grid, state context, "where to find cheap gas in Houston"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once the content categories were clearly different, Google's canonical decisions became consistent. Blog posts started ranking for query-with-intent searches ("why are gas prices high in Houston"). City pages ranked for quantity-intent searches ("cheap gas Houston"). They stopped competing for the same impressions.&lt;/p&gt;

&lt;p&gt;If you have two URL types covering similar entities, they're probably competing whether you intended it or not. Audit your URLs and make sure each type has a clear, different job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 3: Word count is a proxy, not a target
&lt;/h2&gt;

&lt;p&gt;I spent some time obsessing over word counts. I'd seen "1,500 word minimum for ranking" quoted in SEO guides. So I padded pages with filler to hit the number.&lt;/p&gt;

&lt;p&gt;Google did not care. The pages that broke through to indexed weren't the ones with 1,500 words. They were the ones with 800 words of actually useful, page-specific content.&lt;/p&gt;

&lt;p&gt;Word count is a proxy for depth. If you can get to 800 words by writing things that are genuinely useful and page-specific, you're done. If you're padding to hit a target, Google can tell, and the padding doesn't help. The detection isn't a single signal, it's a combination: sentence-level perplexity, semantic distinctness from other pages on your site, contextual relevance to the query intent. Modern ranking models are sophisticated enough to recognize stuffing.&lt;/p&gt;

&lt;p&gt;I went from a 640-word baseline to an 810-word "after" state by adding things that were genuinely useful: the per-state context, an SSR-rendered nearby ZIP grid, a top-stations summary. None of it was filler. The +170 words mattered because of what was in them, not because of the count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 4: GSC verdicts have memory
&lt;/h2&gt;

&lt;p&gt;If a page gets flagged as soft 404, fixing the page doesn't immediately clear the verdict. The recovery flow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fix the page&lt;/li&gt;
&lt;li&gt;Submit a re-validation request in GSC&lt;/li&gt;
&lt;li&gt;Wait 1 to 4 weeks for the re-crawl&lt;/li&gt;
&lt;li&gt;Accept that some URLs won't come back even after you fix them&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;GSC's verdict is sticky. It's not because Google is malicious. It's because the indexation queue is finite and pages with prior negative verdicts get deprioritized. If a page has been "soft 404" for three months and you fix it, Google doesn't drop everything to re-evaluate. It works through the queue, and your fixed page joins the back.&lt;/p&gt;

&lt;p&gt;The implication: when planning a programmatic SEO site, get the page quality right &lt;em&gt;before&lt;/em&gt; you submit URLs to GSC. Submitting a thin page early creates a verdict you'll fight for months. Better to wait, polish, and submit clean.&lt;/p&gt;

&lt;p&gt;I did the opposite. I submitted everything immediately because I was excited. About 700 pages got soft-404'd, and clearing those verdicts took about 8 weeks of patient post-mortem work even after the underlying content was fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 5: Branded search beats backlinks for indexation
&lt;/h2&gt;

&lt;p&gt;This one surprised me.&lt;/p&gt;

&lt;p&gt;Pages that started getting branded search impressions (users typing "gas price check Houston" into Google) got indexed faster than pages that had inbound backlinks from other domains. The branded queries didn't even have to convert. Just the impressions seemed to register.&lt;/p&gt;

&lt;p&gt;Google's signal here seems to be: "people are looking for this specific page on this site, by name." That signal is hard to fake (it requires real users typing branded queries) so Google trusts it heavily. A backlink can be paid for or manufactured. Branded search volume cannot.&lt;/p&gt;

&lt;p&gt;The implication: if you want to accelerate indexation on a specific page, drive direct traffic to it from anywhere. Social posts, email, bio links, even paid ads briefly. The traffic sends a "this page is real and people want it" signal that Google's ranking model picks up.&lt;/p&gt;

&lt;p&gt;I tested this informally. I posted a link to one of my CCNI city pages on Reddit. Got about 80 visits in a day. Within two weeks the page moved from CCNI to indexed, with a top-50 ranking on its target query. I didn't change the page at all. Just sent traffic.&lt;/p&gt;

&lt;p&gt;I'm not claiming this works every time. But the pattern was strong enough that I now bias toward "drive traffic to anything stuck in CCNI" over "build more backlinks to anything stuck in CCNI."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;If I were starting over:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start with 50 hand-written pages, not 50,000 templated ones.&lt;/strong&gt; Get those indexed and ranking before generating the long tail. The long tail benefits from established authority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Differentiate URL types from day one.&lt;/strong&gt; Decide what each URL type is for and don't blur the line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-compute everything.&lt;/strong&gt; If you're going to template at scale, all unique-per-page content (geocoding, internal links, neighbor lists) should be resolved at build time, not runtime. Runtime fetches show up as "loading..." placeholders in the SSR HTML, and Google reads those as thin content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check GSC weekly, not monthly.&lt;/strong&gt; Soft 404 verdicts compound silently. By the time you have 700 of them, recovery is a multi-month project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Save the URL submissions for last.&lt;/strong&gt; Polish first. Submit when the page is actually good. Once a verdict lands, it's sticky.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Programmatic SEO works. But the math isn't "ship 33,620 pages, get 33,620 pages of traffic." It's closer to "ship 33,620 pages, get 6,200 indexed, get traffic on the top 2,000 of those, and the rest are infrastructure." The work isn't generating the pages. The work is earning the right to keep them indexed.&lt;/p&gt;

&lt;p&gt;If you've shipped a programmatic site at scale, what's your indexation rate? I'd love to hear what others are seeing. My hypothesis is that 15-25% indexation is typical for first-year programmatic sites, and the rate climbs over time as the site builds authority. But I have a sample size of one, so I'd love more data points.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>nextjs</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>When zippopotam.us 404s on a real US ZIP: building a 4-tier geocoding fallback</title>
      <dc:creator>GasPriceCheck</dc:creator>
      <pubDate>Thu, 14 May 2026 05:00:00 +0000</pubDate>
      <link>https://dev.to/gaspricecheck/when-zippopotamus-404s-on-a-real-us-zip-building-a-4-tier-geocoding-fallback-47d2</link>
      <guid>https://dev.to/gaspricecheck/when-zippopotamus-404s-on-a-real-us-zip-building-a-4-tier-geocoding-fallback-47d2</guid>
      <description>&lt;p&gt;ZIP-to-lat/lng feels like a solved problem. There are something like 41,000 active US ZIP codes. They're a public dataset. They're geocoded. Multiple free APIs serve them. You wire one up, it works, you ship.&lt;/p&gt;

&lt;p&gt;That's what I thought. Then a user reported that the gas station on his block was showing as "8,247 miles away."&lt;/p&gt;

&lt;p&gt;Quick context: I run a gas price finder side project. When you enter a ZIP, the site looks up that ZIP's lat/lng, then sorts nearby gas stations by driving distance. If the ZIP's lat/lng resolves to (0, 0), the Haversine math returns absurd distances because (0, 0) is in the middle of the Atlantic Ocean off the coast of Africa.&lt;/p&gt;

&lt;p&gt;The user's ZIP was 75072. McKinney, Texas. Population 200,000. Not some obscure rural P.O. box. A real ZIP code with a real population center.&lt;/p&gt;

&lt;p&gt;My geocoder was returning (0, 0).&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually happening
&lt;/h2&gt;

&lt;p&gt;My geocoder, at the time, was a single fetch:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;zipToLatLng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;string&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.zippopotam.us/us/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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;places&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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;places&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;longitude&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;zippopotam.us is free, fast, and was working for everything I'd tested. So when 75072 returned a 404, my fallback was the (0, 0) sentinel. Garbage in, garbage out.&lt;/p&gt;

&lt;p&gt;I dug a little. Curled the API directly:&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;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; https://api.zippopotam.us/us/75072
HTTP/1.1 404 Not Found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I tried 75070, the neighboring ZIP:&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;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-i&lt;/span&gt; https://api.zippopotam.us/us/75070
HTTP/1.1 200 OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So 75070 is in zippopotam's data, 75072 isn't. Two ZIPs in the same city, one block apart, one of them missing. There's no announced data freshness commitment from zippopotam (it's a free service maintained by one person), so this isn't a bug to report. It's just a gap.&lt;/p&gt;

&lt;p&gt;I spot-checked. Out of a sample of 100 ZIPs from my user logs, zippopotam returned 404 on 4 of them. A 4% gap rate. On a programmatic site with 33,620 pages, that's about 1,300 pages where the geocoder silently returned (0, 0) and broke distance math.&lt;/p&gt;

&lt;p&gt;This is the moment when "I'll just use a free API" stops scaling.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chain I shipped
&lt;/h2&gt;

&lt;p&gt;What I needed wasn't a better single source. It was a chain of sources, each with different tradeoffs, ordered from cheapest to most expensive:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Static file lookup&lt;/strong&gt; (instant, zero network)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis cache&lt;/strong&gt; (instant, one network hop)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;zippopotam.us&lt;/strong&gt; (~150ms, free, has gaps)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nominatim&lt;/strong&gt; (~400ms, free, rate-limited, covers gaps)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sentinel (0, 0)&lt;/strong&gt; with degraded behavior&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the actual implementation, slightly cleaned up:&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Redis&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@upstash/redis&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;zipContent&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./zipContent.json&lt;/span&gt;&lt;span class="dl"&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;redis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEnv&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;LatLng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;zipToLatLng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zip&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;LatLng&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Tier 1: static file (33,620 pre-resolved ZIPs)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;staticEntry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zipContent&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;staticEntry&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;staticEntry&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;staticEntry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;staticEntry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lng&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Tier 2: Redis cache (for ZIPs we've resolved at runtime)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;get&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;LatLng&lt;/span&gt;&lt;span class="o"&gt;&amp;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;zip:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Tier 3: zippopotam.us&lt;/span&gt;
  &lt;span class="k"&gt;try&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.zippopotam.us/us/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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;places&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;latitude&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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;places&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;longitude&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zip:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&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;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// network error or timeout, fall through&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Tier 4: Nominatim&lt;/span&gt;
  &lt;span class="k"&gt;try&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://nominatim.openstreetmap.org/search?postalcode=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;zip&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;amp;country=us&amp;amp;format=json&amp;amp;limit=1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&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;User-Agent&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;GasPriceCheck/1.0 (contact@example.com)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&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;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;if &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;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="na"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;lon&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zip:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&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;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// network error or timeout, fall through&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Tier 5: sentinel&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;lat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&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;Five tiers, ordered by cost. Each one only runs if the previous one didn't find an answer. The static file handles 99% of requests in zero time. Redis handles the runtime-resolved ones. The two API tiers are the actual fallbacks.&lt;/p&gt;

&lt;p&gt;A few notes on the implementation choices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tier 1: pre-resolving everything you can ahead of time
&lt;/h2&gt;

&lt;p&gt;The biggest performance win wasn't the chain. It was Tier 1: a static JSON file with every ZIP I knew about, pre-resolved to lat/lng once at build time.&lt;/p&gt;

&lt;p&gt;I wrote a Python script (&lt;code&gt;enrich-zip-latlng.py&lt;/code&gt;) that walks my full ZIP list, calls zippopotam for each one, falls back to Nominatim for the gaps, and writes the results to &lt;code&gt;zipContent.json&lt;/code&gt;. The script is resumable: it checkpoints to a JSON file every 100 ZIPs, so if it crashes (and it did, several times, on intermittent rate limits), you restart and it picks up where it left off.&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;CHECKPOINT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zip_latlng_progress.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;OUTPUT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zipContent.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_progress&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;CHECKPOINT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CHECKPOINT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resolved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;remaining&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;load_zip_list&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zip_code&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# try zippopotam, fall back to Nominatim
&lt;/span&gt;    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.zippopotam.us/us/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;zip_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;latitude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;places&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;longitude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://nominatim.openstreetmap.org/search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postalcode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;zip_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;us&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;format&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ZipEnricher/1.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lon&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After running this against all 33,620 ZIPs (took about 9 hours of wall clock time, mostly idle waiting for rate limits), I had 100% coverage. Every known ZIP resolves in zero network calls at runtime.&lt;/p&gt;

&lt;p&gt;The runtime fallback chain is now a safety net for the ZIPs that aren't in &lt;code&gt;zipContent.json&lt;/code&gt; (mostly newly-issued ZIPs and edge-case P.O. box codes). It's load-bearing maybe 0.1% of the time. The other 99.9% of requests hit Tier 1 and exit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tier 4: Nominatim's etiquette is real
&lt;/h2&gt;

&lt;p&gt;Nominatim is OpenStreetMap's geocoder. It's free. It's also explicit about its rate limits: 1 request per second, max. They publish a usage policy. They will rate-limit you (or ban your IP) if you violate it.&lt;/p&gt;

&lt;p&gt;Two things matter for keeping Nominatim happy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Set a real User-Agent header.&lt;/strong&gt; They block requests with the default &lt;code&gt;node-fetch/1.0&lt;/code&gt; UA. Use something like &lt;code&gt;MyApp/1.0 (contact@example.com)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throttle.&lt;/strong&gt; If you're calling Nominatim from a script, sleep at least 1 second between calls. From an API route serving real users, this is fine because user traffic is naturally sparse.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're going to depend on Nominatim at scale, you should run your own instance. It's open source and Dockerized. For a side project hitting it 1-2 times a day, the public instance is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tier 5: the sentinel and graceful degradation
&lt;/h2&gt;

&lt;p&gt;When all four tiers fail, my function returns &lt;code&gt;{ lat: 0, lng: 0 }&lt;/code&gt;. The downstream code needs to handle this without producing garbage.&lt;/p&gt;

&lt;p&gt;In my case, the downstream consumer is a "nearest gas stations" sorter that uses Haversine distance. If the user's coords are (0, 0), Haversine returns absurd distances for every station. So I added a guard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userCoords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;zipToLatLng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userCoords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lat&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;userCoords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lng&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;stations&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// unsorted, no distance column&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sortByDistance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userCoords&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If we can't geocode, we still show stations. We just don't sort by distance, and we don't display the distance column. The page degrades gracefully instead of showing "8,247 miles away" everywhere.&lt;/p&gt;

&lt;p&gt;This is the "fail open" pattern: when the dependency fails, the user still gets a useful page, just with less information. Better than failing the whole request.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell my past self
&lt;/h2&gt;

&lt;p&gt;Three things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One. "Free APIs have gaps" is not a hypothesis, it's a default assumption.&lt;/strong&gt; Any API where the provider is one person, a small org, or a "we'll keep this up as a community resource" project will have data quality issues somewhere. Plan for it from day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two. Pre-resolve the long tail at build time, not request time.&lt;/strong&gt; Runtime API calls are a tax on every user. If you have a finite, enumerable input space (like US ZIP codes), resolve the whole space once and cache the results to disk. This collapses the runtime cost from "1 API call per request" to "1 file lookup per request."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three. Build the chain before you need it.&lt;/strong&gt; The temptation when the first source works is to ship the single-source version and add fallbacks "later." Later means after a user reports a bug. Building the chain up front takes maybe 2 hours and saves you the embarrassment of "8,247 miles away" emails.&lt;/p&gt;

&lt;p&gt;The architecture lesson here generalizes beyond ZIPs. Anywhere you have a dependency that maps "A → B" deterministically and the input space is finite, the move is the same: pre-resolve at build, cache at runtime, fall back to alternative providers, degrade gracefully when all else fails. ZIP geocoding, currency conversion, language code lookups, country data, time zone data, postal codes for any country with public data: same pattern.&lt;/p&gt;

&lt;p&gt;The whole rebuild took about a weekend. I'd do it again in an afternoon now that I know the shape. The hard part wasn't the code, it was admitting that "free API + sentinel" wasn't actually a fallback strategy. It was a single-source-of-truth dressed up as one.&lt;/p&gt;

&lt;p&gt;If you've shipped a layered geocoding chain on top of zippopotam, Nominatim, or a paid alternative, drop your tier ordering in the comments. Curious what other chains people are running.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>nextjs</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Google deindexed half of my Next.js site. Here's the four-phase recovery.</title>
      <dc:creator>GasPriceCheck</dc:creator>
      <pubDate>Thu, 07 May 2026 05:00:00 +0000</pubDate>
      <link>https://dev.to/gaspricecheck/google-deindexed-half-of-my-nextjs-site-heres-the-four-phase-recovery-284j</link>
      <guid>https://dev.to/gaspricecheck/google-deindexed-half-of-my-nextjs-site-heres-the-four-phase-recovery-284j</guid>
      <description>&lt;p&gt;I run a side project: a gas price finder that's mostly programmatic content. Roughly 33,620 ZIP code pages plus a few hundred state and city pages, all built on Next.js 15 with ISR. It runs on Vercel, fronted by Cloudflare for DNS.&lt;/p&gt;

&lt;p&gt;For about eight months it was indexing fine. Traffic was small but growing. Then on April 11 I checked Google Search Console and saw something I'd never seen at this scale: 87 URLs flagged as "not found (404)," 61 flagged as "soft 404," and a chunk of others sitting in "crawled, currently not indexed."&lt;/p&gt;

&lt;p&gt;I'm a data analyst. This is my side project, not my day job. So I had a weekend, a coffee subscription, and the GSC export. Here's the four-phase recovery, what each phase actually fixed, and the things I'd do differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the damage actually was
&lt;/h2&gt;

&lt;p&gt;GSC's URL Inspection tool is the only way to figure out what Google thinks of any specific URL. The 87 hard 404s broke down into two groups when I sampled them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stale city slugs.&lt;/strong&gt; URLs like &lt;code&gt;/portland&lt;/code&gt; and &lt;code&gt;/columbus&lt;/code&gt; that Google had picked up from somewhere (early sitemap drafts, third-party references, link typos in old social posts). My live routes use state-suffix disambiguation for city names that exist in multiple states (&lt;code&gt;/portland-or&lt;/code&gt; and &lt;code&gt;/portland-me&lt;/code&gt; for Portland, similar pattern for Columbus). Google still had the un-disambiguated slugs cached and was hitting 404s when it tried to refresh them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typo URLs in third-party referrers.&lt;/strong&gt; A handful of blog posts I'd written had link typos to my own site. Google followed those, hit 404s, and now thought those URLs were canonical.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The 61 soft 404s were a different beast. These were real ZIP code pages that returned 200 OK but Google decided didn't have enough content to be "real" pages. Looking at the SSR output, I could see why: when a ZIP wasn't cached in Redis (which was most of the long tail), the page rendered a hero, a search box, and a thin "search results loading" placeholder. About 640 visible words in the SSR HTML. From Google's perspective: this is a glorified 404 wearing a 200 costume.&lt;/p&gt;

&lt;p&gt;The "crawled, currently not indexed" pile was the soft-404 pile's pre-stage. Google had crawled them, decided they weren't worth indexing, but hadn't formally classified them as soft 404s yet.&lt;/p&gt;

&lt;p&gt;That's the diagnosis. Now the fixes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: The boring redirect fix
&lt;/h2&gt;

&lt;p&gt;This phase is unglamorous and important. For each of the 87 hard 404s, I had to decide: does this URL have a clear successor, and if so, where do I redirect it?&lt;/p&gt;

&lt;p&gt;I did this in &lt;code&gt;next.config.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STALE_CITY_REDIRECTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/portland&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/portland-or&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;permanent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/midland&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/blog/gas-prices-midland&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;permanent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...64 more&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;redirects&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;STALE_CITY_REDIRECTS&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;&lt;code&gt;permanent: true&lt;/code&gt; emits a 308. That tells Google "this URL has moved permanently, transfer signals to the destination." A 301 would also work; I went with 308 because Next.js's defaults align with that and I didn't want to fight the framework.&lt;/p&gt;

&lt;p&gt;The unglamorous part: most of the 87 URLs didn't have an obvious destination. Some pointed to cities I no longer covered. Some pointed to ZIPs that didn't exist. For those, I redirected to the parent state page (e.g. an unrecognized Texas city slug pointing to &lt;code&gt;/texas&lt;/code&gt;) so the user lands somewhere useful, and the link equity isn't dropped on the floor.&lt;/p&gt;

&lt;p&gt;Total output of Phase 1: 66 redirects, deployed in one commit. Pushed it. Watched GSC for a week.&lt;/p&gt;

&lt;p&gt;Result: about half the hard 404s validated. The other half stuck. Why?&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1.5: The Cloudflare redirect chain I didn't know I had
&lt;/h2&gt;

&lt;p&gt;When I inspected one of the still-failing URLs in GSC, the URL Inspection tool said "page with redirect" and showed a chain. Google was hitting &lt;code&gt;http://example.com/portland&lt;/code&gt;, getting redirected to &lt;code&gt;https://www.example.com/portland&lt;/code&gt;, and then redirected again to &lt;code&gt;https://www.example.com/portland-or&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two hops. Google does not like two hops.&lt;/p&gt;

&lt;p&gt;I had Cloudflare in front of Vercel as a DNS proxy. Cloudflare was handling the http to https and apex to www redirect. Vercel was handling the slug-to-slug redirect. Each layer worked. Together they made a chain.&lt;/p&gt;

&lt;p&gt;The fix was a single Cloudflare Redirect Rule that does both transforms in one hop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;If: hostname matches "example.com" AND scheme is http
Then: 308 to https://www.example.com (preserving the original path)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that rule landed, the chain collapsed from 2 hops to 1. Google reprocessed and started clearing the rest of the hard 404s. Lesson I should have known: when you have multiple layers (CDN, edge, framework), each one defaulting to "I'll handle the redirect" stacks. Audit the hop count with &lt;code&gt;curl -IL &amp;lt;url&amp;gt;&lt;/code&gt; and look for chains.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: The real work (thin SSR content)
&lt;/h2&gt;

&lt;p&gt;The 61 soft 404s couldn't be redirected away. These were real pages I wanted indexed. Google just thought they were thin.&lt;/p&gt;

&lt;p&gt;The diagnosis was straightforward once I started reading my own SSR output. When a ZIP wasn't cached, the page rendered:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A hero with the ZIP, city, state&lt;/li&gt;
&lt;li&gt;A search box&lt;/li&gt;
&lt;li&gt;A "loading prices..." placeholder (waiting for client-side fetch)&lt;/li&gt;
&lt;li&gt;A footer&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. Maybe 640 visible words, of which 400 were the footer and global nav. The actual page-specific content was a hero header and a placeholder.&lt;/p&gt;

&lt;p&gt;The fix had three sub-components.&lt;/p&gt;

&lt;h3&gt;
  
  
  2a. Server-render the nearby ZIP grid
&lt;/h3&gt;

&lt;p&gt;I had a helper called &lt;code&gt;getNearbyZips(zip, radius)&lt;/code&gt; that returned ZIP codes within a given mile radius. I'd been using it on the client. I moved it to the server component so the SSR HTML included an actual grid of "nearby ZIP codes" with links.&lt;/p&gt;

&lt;p&gt;This added about 80 words of unique-per-ZIP content (different neighbors for each ZIP). More importantly, it added 8-12 internal links per page, which gave Google more signal about the URL's place in the site graph.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: client-side, invisible to Google&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nearby&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useNearbyZips&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// After: server-rendered, visible to Google&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nearby&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getNearbyZips&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;section&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;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Nearby ZIP codes&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h2&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;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;nearby&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zipCode&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;zipCode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;zipCode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;zipCode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&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;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&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;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2b. Unconditional save tips
&lt;/h3&gt;

&lt;p&gt;I added a hand-written "How to save on gas in [city]" section that rendered regardless of cache state. About 120 words of static-but-locally-relevant content per city. This is templated, but with enough variable interpolation that no two pages have identical text.&lt;/p&gt;

&lt;h3&gt;
  
  
  2c. State backlink with name fallback
&lt;/h3&gt;

&lt;p&gt;Every ZIP page already had a "Back to [state] state guide" link, but it relied on a state abbreviation lookup that returned &lt;code&gt;null&lt;/code&gt; for some edge cases. So those pages were rendering "Back to undefined state guide" or worse, no link at all. Fixed it with a fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stateName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getStateByAbbr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;getStateByName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Small fix, but it meant every ZIP page now had a working internal link to its parent state, which closes a major site-graph gap.&lt;/p&gt;

&lt;p&gt;After Phase 2, my SSR word count went from 640 to about 890 on previously-thin pages. That's the threshold I cared about. Google's "soft 404" verdict is based on relative content depth, not an absolute word count, but more depth is always better than less.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: The geocoding gap I didn't know I had
&lt;/h2&gt;

&lt;p&gt;While I was at it, I noticed something weird. Some ZIP pages were rendering with lat/lng of (0, 0). This made the distance calculations on the page nonsensical ("nearest gas station: 8,247 miles away"). It also meant the "nearby ZIPs" grid was showing up empty for those pages.&lt;/p&gt;

&lt;p&gt;The cause: my ZIP-to-lat/lng resolver had a single source: &lt;code&gt;zippopotam.us&lt;/code&gt;. It's free, fast, and most of the time correct. But for some valid US ZIPs (75072 in McKinney TX, for one), it returns a 404.&lt;/p&gt;

&lt;p&gt;I rebuilt the resolver as a 4-tier fallback chain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;zipContent.json&lt;/code&gt; (a static file with 33,620 ZIPs and pre-resolved coords)&lt;/li&gt;
&lt;li&gt;Redis cache (per-request resolved coords)&lt;/li&gt;
&lt;li&gt;zippopotam.us API&lt;/li&gt;
&lt;li&gt;Nominatim API (slower but covers the gaps)&lt;/li&gt;
&lt;li&gt;Placeholder (0, 0) with degraded behavior&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'll write up the chain in detail in a separate post. The point for this post: when GSC flagged these pages as soft 404s, the broken geocoding was part of the picture even though it wasn't the headline issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 7B: The second sweep
&lt;/h2&gt;

&lt;p&gt;Three days after deploying Phases 1 through 3, I ran the GSC validation again. About 80% of the URLs had cleared. Some hadn't. So I wrote a script (&lt;code&gt;find-redirect-candidates.js&lt;/code&gt;) that programmatically tested every plausible city slug variant against the live site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;variants&lt;/span&gt; &lt;span class="o"&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;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;city&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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;-&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;/cheap-gas-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;city&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;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="nx"&gt;v&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;variants&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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;This caught 35 more 404s I hadn't found in the GSC export. Stale links in old blog posts I'd written, third-party links from a directory submission I'd forgotten about, typo URLs in my own social media posts. Each one got a redirect.&lt;/p&gt;

&lt;p&gt;Phase 7B added 35 new redirects, bringing the total to 101. I deployed those, and the second GSC validation came back clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 8: Per-state context (the depth fix that actually moved the needle)
&lt;/h2&gt;

&lt;p&gt;After all the redirects and SSR enrichment, I still had some pages stuck in "crawled, currently not indexed." Word count was up. Internal links were up. But Google was still skeptical.&lt;/p&gt;

&lt;p&gt;The thing I hadn't done: make the templated content actually different across pages. My "save tips" section was different by city, but my page-level content above the fold was nearly identical. A page about ZIPs in California and a page about ZIPs in Maine had no state-specific context.&lt;/p&gt;

&lt;p&gt;I built a &lt;code&gt;stateContext.ts&lt;/code&gt; module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;STATE_CONTEXT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;CA&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;California's gas prices are shaped by the state's unique CARB...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;TX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Texas typically has some of the lowest gas prices in the country...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...15 hand-written for top-traffic states&lt;/span&gt;
  &lt;span class="c1"&gt;// ...35 generated from a parameterized template for the rest&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getStateContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&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="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;STATE_CONTEXT&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;defaultContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&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;The 15 hand-written paragraphs are 80 to 120 words each. They explain the state's gas tax, refinery capacity, regulatory regime, and seasonal pricing patterns. These are the things you'd say to a friend if they asked "why are gas prices weird in California?"&lt;/p&gt;

&lt;p&gt;The other 35 states get a templated paragraph with state-specific variables (avg price, neighboring states, gas tax rate). Templated, but with enough variation that each is unique.&lt;/p&gt;

&lt;p&gt;After deploying Phase 8, the previously-stuck pages started getting indexed within 10 days. Not all of them. But enough that I stopped worrying about them.&lt;/p&gt;

&lt;p&gt;Final SSR word count on a representative ZIP page when measured post-deploy: 810 visible words, up from 635 before Phase 8 alone. The whole journey took it from 640 to 810: a +170 word lift made of mostly per-state context plus the SSR enrichment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnostic that lied to me
&lt;/h2&gt;

&lt;p&gt;One quick aside that became its own post. While diagnosing the soft 404s, I wrote a script to grep the SSR output for content markers ("does this page have an EIA average price rendered?"). The script reported zero matches across 14 URLs. I spent an hour debugging the data layer before realizing the script was broken.&lt;/p&gt;

&lt;p&gt;The cause: React inserts HTML comments between adjacent text expressions during SSR. My regex was failing on the comment boundary. The data was rendering correctly the whole time. My detector was the bug.&lt;/p&gt;

&lt;p&gt;I wrote that one up separately. The summary: strip HTML comments before any content matching on Next.js SSR output.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Before:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;87 hard 404s in GSC&lt;/li&gt;
&lt;li&gt;61 soft 404s in GSC&lt;/li&gt;
&lt;li&gt;~640 SSR words on cached-miss ZIP pages&lt;/li&gt;
&lt;li&gt;1 source of truth for ZIP geocoding (and gaps)&lt;/li&gt;
&lt;li&gt;2-hop redirect chain from http apex to https www&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;0 hard 404s (101 redirects in &lt;code&gt;next.config.mjs&lt;/code&gt;, 1 Cloudflare Redirect Rule)&lt;/li&gt;
&lt;li&gt;0 soft 404s after Phase 8 (per latest GSC validation)&lt;/li&gt;
&lt;li&gt;~810 SSR words on the same pages&lt;/li&gt;
&lt;li&gt;4-tier geocoding chain with 100% coverage of 33,620 ZIPs&lt;/li&gt;
&lt;li&gt;1-hop redirect&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total commits: 7. Total deploy waves: 3 (April 17, April 23, April 27). Total weekend hours: I stopped counting somewhere around 14.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I'd do differently
&lt;/h2&gt;

&lt;p&gt;If I were starting this side project again:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Set up GSC URL Inspection alerts before launch.&lt;/strong&gt; I had GSC connected but wasn't watching it daily. The 404 problem accumulated for weeks before I noticed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a &lt;code&gt;curl -IL&lt;/code&gt; check to my deploy script.&lt;/strong&gt; A redirect chain check would have caught the http+https+slug double-hop before it became a Google problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-render the unique parts of every templated page from day one.&lt;/strong&gt; Anything that varies per page should be in the SSR HTML, not the client bundle. Loading states are the enemy of programmatic SEO.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hand-write the top 10-20% of templated content.&lt;/strong&gt; The remaining 80% can be generated, but the long-tail-of-the-long-tail is where Google will smell templating and dock you. Hand-writing the highest-traffic variants is high leverage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a diff log of redirect rules.&lt;/strong&gt; Mine grew to 101 entries in &lt;code&gt;next.config.mjs&lt;/code&gt; and I'd already lost track of which ones I added when. A separate JSON file with timestamps would have been smarter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't use real URLs from your own site in code examples or narrative text in technical articles.&lt;/strong&gt; Markdown auto-links them into real backlinks, which means typos and placeholders in your article become 404 reports on your site three weeks later. Use &lt;code&gt;example.com&lt;/code&gt; for everything except actual call-to-action links.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I learned about Google's verdict mechanism
&lt;/h2&gt;

&lt;p&gt;Three things I genuinely didn't know before this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Soft 404 is sticky.&lt;/strong&gt; Once Google decides a page is a soft 404, fixing the page doesn't immediately clear the verdict. You have to ask GSC to re-validate, wait 1-4 weeks for re-crawl, and accept that some of those URLs will not come back even after you fix them. The verdict has memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Crawled, currently not indexed" is the bench.&lt;/strong&gt; Google has a finite indexation budget per site. Pages they don't think are worth indexing go on the bench. You can move pages off the bench by improving them, but it's not automatic and it's not fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Internal redirect hops add up.&lt;/strong&gt; Each hop is a small signal-loss for Google. If your CDN does one redirect and your framework does another, you're below capacity even if both are individually correct. Audit your hop count.&lt;/p&gt;

&lt;p&gt;If you've been through a similar programmatic-SEO recovery, I'd love to hear what your phase breakdown looked like. Mine was four phases over two weekends. The longest phase by far wasn't the bulk redirects (that was a few hours). It was Phase 8: the part where I had to admit my templated content was actually pretty thin and rewrite the per-state context by hand.&lt;/p&gt;

&lt;p&gt;Templating gets you to 33,620 pages fast. Earning the right to keep those pages indexed takes longer.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>seo</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why your `[^&lt;]+` regex is silently breaking on React SSR output</title>
      <dc:creator>GasPriceCheck</dc:creator>
      <pubDate>Thu, 30 Apr 2026 06:02:34 +0000</pubDate>
      <link>https://dev.to/gaspricecheck/why-your-regex-is-silently-breaking-on-react-ssr-output-2h</link>
      <guid>https://dev.to/gaspricecheck/why-your-regex-is-silently-breaking-on-react-ssr-output-2h</guid>
      <description>&lt;p&gt;Picture this. You've shipped a programmatic SEO site, a few thousand pages of templated content. Google flags 14 URLs as soft 404s. You write a quick diagnostic: hit each URL, fetch the SSR HTML, check for a few content markers (a price string, a state average, a section header). Confirm what's really rendering, fix what's missing, move on.&lt;/p&gt;

&lt;p&gt;That was the plan. Forty minutes in, my script told me 0 of 14 pages had any EIA price data rendered. None. I was about to dig into the data fetching layer when something nagged at me. I curled one of the URLs by hand. The price was right there in the HTML. Plain text, easy to spot.&lt;/p&gt;

&lt;p&gt;The script was lying. The regex was lying.&lt;/p&gt;

&lt;p&gt;It took me longer than I want to admit to figure out what was happening, so here's the writeup so you don't burn the same hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  The regex that "worked"
&lt;/h2&gt;

&lt;p&gt;The diagnostic was straightforward. For each ZIP code page, I wanted to detect whether the EIA state average price was rendered in the SSR HTML. The component looked roughly like this:&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;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  Texas average: $3.42 per gallon as of &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;eiaDate&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;.
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;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;So I grepped the SSR output for the marker pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/Texas average: &lt;/span&gt;&lt;span class="se"&gt;\$([^&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt; per gallon/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The capture group uses &lt;code&gt;[^&amp;lt;]+&lt;/code&gt; to pick up "everything until the next tag." Standard pattern. I've used variations of this in dozens of throwaway scrapers.&lt;/p&gt;

&lt;p&gt;Across all 14 URLs, this regex returned no match. Not "match with empty capture." No match at all.&lt;/p&gt;

&lt;p&gt;Meanwhile, when I curled the same URL and read the response, the rendered text was right there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Texas average: $2.97 per gallon as of 4/22/2026.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;How does a regex miss text that's literally in the string?&lt;/p&gt;

&lt;h2&gt;
  
  
  What React is actually putting in your HTML
&lt;/h2&gt;

&lt;p&gt;Here's the thing nobody told me about server-rendered React. When you have multiple adjacent text expressions inside a single element, React inserts an HTML comment as a hydration boundary marker. The actual SSR output for that paragraph looked like:&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;p&amp;gt;&lt;/span&gt;Texas average: $&lt;span class="c"&gt;&amp;lt;!-- --&amp;gt;&lt;/span&gt;2.97&lt;span class="c"&gt;&amp;lt;!-- --&amp;gt;&lt;/span&gt; per gallon as of &lt;span class="c"&gt;&amp;lt;!-- --&amp;gt;&lt;/span&gt;4/22/2026&lt;span class="c"&gt;&amp;lt;!-- --&amp;gt;&lt;/span&gt;.&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each &lt;code&gt;{expression}&lt;/code&gt; interpolation gets bracketed by &lt;code&gt;&amp;lt;!-- --&amp;gt;&lt;/code&gt;. This is intentional. React uses these markers during hydration to know where one text node ends and the next one begins. Without them, React can't reconcile the SSR text with what its virtual DOM says should be there, because adjacent text nodes get merged in the browser.&lt;/p&gt;

&lt;p&gt;So when my regex hits &lt;code&gt;Texas average: $&lt;/code&gt;, the next character is &lt;code&gt;&amp;lt;&lt;/code&gt; (start of the comment). The &lt;code&gt;[^&amp;lt;]+&lt;/code&gt; capture group requires at least one non-&lt;code&gt;&amp;lt;&lt;/code&gt; character. It fails immediately. The regex moves on, finds no other "Texas average:" anchor in the page, and reports no match.&lt;/p&gt;

&lt;p&gt;The price ($2.97) is in the HTML. It's just not adjacent to the anchor text. There's a comment node between them.&lt;/p&gt;

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

&lt;p&gt;Strip the comments before any content matching. Two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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;stripped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;!--&lt;/span&gt;&lt;span class="se"&gt;[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;--&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&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;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripped&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/Texas average: &lt;/span&gt;&lt;span class="se"&gt;\$([^&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt; per gallon/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;[\s\S]&lt;/code&gt; rather than &lt;code&gt;.&lt;/code&gt; because comments can span multiple lines and &lt;code&gt;.&lt;/code&gt; in JavaScript doesn't match newlines without the &lt;code&gt;s&lt;/code&gt; flag (and &lt;code&gt;s&lt;/code&gt; flag support is patchy enough that I just default to &lt;code&gt;[\s\S]&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Once I added that one line, the regex worked on all 14 URLs. The EIA data was rendering correctly the whole time. My "soft 404 root cause" was a bug in my diagnostic, not a real content gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this gotcha matters more than you think
&lt;/h2&gt;

&lt;p&gt;The reason I'm writing this up: the failure mode is silent and high-impact.&lt;/p&gt;

&lt;p&gt;If your regex returned an obvious garbage value, you'd debug it in five minutes. But mine returned no match at all. That's the same return value as "this content is genuinely missing." Which is the exact thing I was trying to detect. The diagnostic was indistinguishable from the bug it was looking for.&lt;/p&gt;

&lt;p&gt;I trusted the output. I started forming hypotheses based on the output. "The EIA fetch must be failing on the server. Let me check my fallback chain. Let me check Redis." I burned an hour on those wrong paths because the symptom was confirmed by my (broken) detector.&lt;/p&gt;

&lt;p&gt;Two takeaways that generalize beyond this exact case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, when you grep server-rendered React HTML, always strip comments first.&lt;/strong&gt; Any regex with &lt;code&gt;[^&amp;lt;]+&lt;/code&gt;, &lt;code&gt;\w+&lt;/code&gt;, or word-boundary anchors will trip on the hydration markers. Some browser View Source viewers hide comments by default, which is part of why this gotcha is invisible. View the raw response with &lt;code&gt;curl -s URL | head -200&lt;/code&gt; and look for the &lt;code&gt;&amp;lt;!-- --&amp;gt;&lt;/code&gt; pattern. You'll see them everywhere, especially on text-heavy pages with lots of variable interpolation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, validate your diagnostic before you trust its output.&lt;/strong&gt; Run it against a known-good page. If your "missing content" detector reports content as missing on a page where you can visually confirm the content exists, your detector is broken, not the content. I should have done this sanity check before chasing fix hypotheses. I didn't, because the detector was "obviously simple."&lt;/p&gt;

&lt;p&gt;This bit me on a Next.js 15 / React 18 project. I checked: the same hydration-comment behavior is documented in the Next.js docs as part of how React handles hydration. It's not going away. If you're parsing SSR HTML programmatically, assume comments are everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  One last thing worth knowing
&lt;/h2&gt;

&lt;p&gt;There's a sister gotcha. If you're using &lt;code&gt;cheerio&lt;/code&gt; or another DOM parser instead of regex, you don't have this problem. The DOM parser walks the tree, and adjacent text nodes are joined when you call &lt;code&gt;.text()&lt;/code&gt;. So &lt;code&gt;$('p').text()&lt;/code&gt; returns "Texas average: $2.97 per gallon as of 4/22/2026." with no comment artifacts.&lt;/p&gt;

&lt;p&gt;But if you're using regex (faster, simpler for one-off scripts), strip first, match second. Or write a one-line helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripComments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;!--&lt;/span&gt;&lt;span class="se"&gt;[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;--&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop it at the top of every SSR-parsing script you write. Future-you will thank you.&lt;/p&gt;

&lt;p&gt;That's the whole post. One bug, one fix, one hour I'm not getting back. Hope I saved you the same hour.&lt;/p&gt;

&lt;p&gt;If you've hit a similar silent-failure-mode debugging story, drop it in the comments. I collect these.&lt;/p&gt;

</description>
      <category>react</category>
      <category>nextjs</category>
      <category>javascript</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
