<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Mohamed Gado]]></title><description><![CDATA[Notes from shipping web and mobile apps with React, Next.js and React Native: SEO, hydration bugs, CSS rendering and Search Console. Originals at mohamedgado.co]]></description><link>https://mohamedgado.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 12 Nov 2026 08:09:03 GMT</lastBuildDate><atom:link href="https://mohamedgado.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Cutting a Next.js page from 854 KB to 459 KB of HTML by weighing the RSC payload]]></title><description><![CDATA[My /projects page shipped 854 KB of HTML, mostly repeats: one SVG path 98 times, JSON-LD copies, row classes. How I found and cut each.]]></description><link>https://mohamedgado.hashnode.dev/nextjs-page-weight-rsc-payload</link><guid isPermaLink="true">https://mohamedgado.hashnode.dev/nextjs-page-weight-rsc-payload</guid><category><![CDATA[Next.js]]></category><category><![CDATA[performance]]></category><category><![CDATA[React]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Mohamed Gado]]></dc:creator><pubDate>Wed, 11 Nov 2026 18:54:41 GMT</pubDate><content:encoded><![CDATA[<p>My <code>/projects</code> page in Next.js 16 shipped 854 KB of HTML for a list of 49 projects. Almost all of that weight was the same few strings repeated, and in the App Router every repeat ships twice: once as HTML and once more inside the inline React Server Components payload (the <code>self.__next_f.push</code> scripts at the bottom of the page). Three changes took it to 459 KB: an SVG sprite for the icon on every row, JSON-LD that links to detail pages instead of copying them, and named Tailwind utilities for the row classes. A fourth stopped the page from prefetching 36 routes it didn't need.</p>
<h2>Why is my Next.js page HTML so large?</h2>
<p>Open view-source on any App Router page and scroll to the bottom. After the HTML you will find <code>self.__next_f.push([1,"..."])</code> scripts. That is the RSC payload, the serialized component tree React uses to hydrate the page and to navigate without a full reload. Every element a server component renders appears in it as data, props included.</p>
<p>So a server component that renders an inline <code>&lt;svg&gt;</code> 49 times puts that path in the document 98 times. On my page today the payload is 261 KB of the 460 KB document.</p>
<p>Compression hides this. The live page is 33 KB with brotli and 45 KB with gzip, because repeated strings are what compressors handle best. That is why the problem never showed up as a slow download. The phone still has to decompress, parse and hold every byte, and that cost lands on the main thread of the slowest device that opens the page.</p>
<h2>How do I find what is making the page heavy?</h2>
<p>Weigh it by parts before changing anything. My first guess was the usual one, large props passed into client components and duplicated into the payload. Every component on <code>/projects</code> was a server component, so that guess was wrong, and work planned around it would have saved nothing.</p>
<p>This script fetches a page and prints the HTML size, the RSC payload, the JSON-LD, and the long attribute values that repeat most:</p>
<pre><code class="language-js">// weigh.mjs: node weigh.mjs https://example.com/page
const html = await (await fetch(process.argv[2])).text()
const kb = (n) =&gt; `${(n / 1024).toFixed(0)} KB`

const flight = [...html.matchAll(/self\.__next_f\.push\(\[1,"([\s\S]*?)"\]\)&lt;\/script&gt;/g)]
  .reduce((sum, m) =&gt; sum + m[1].length, 0)
const jsonLd = [...html.matchAll(/&lt;script type="application\/ld\+json"&gt;([\s\S]*?)&lt;\/script&gt;/g)]
  .reduce((sum, m) =&gt; sum + m[1].length, 0)
console.log(`HTML ${kb(html.length)}, RSC payload ${kb(flight)}, JSON-LD ${kb(jsonLd)}`)

// Long SVG paths and class strings, in the HTML (d="…") and the payload (\"d\":\"…\").
const counts = new Map()
for (const m of html.matchAll(/(?:\bd|class|className)\\?"?[=:]\\?"([^"\\]{80,})/g)) {
  counts.set(m[1], (counts.get(m[1]) ?? 0) + 1)
}
;[...counts]
  .map(([value, n]) =&gt; ({ n, bytes: n * value.length, value: value.slice(0, 50) }))
  .sort((a, b) =&gt; b.bytes - a.bytes)
  .slice(0, 5)
  .forEach((r) =&gt; console.log(`${kb(r.bytes).padStart(7)}  ${String(r.n).padStart(3)}x  ${r.value}…`))
</code></pre>
<p>On the 854 KB version the top line was a single 2,115-character path, the GitHub mark, 98 times.</p>
<h2>Fix 1: draw repeated icons once with an SVG sprite</h2>
<p>The GitHub link on each row rendered its own <code>&lt;svg&gt;</code> with the full path. I moved the paths into one hidden sprite rendered by the root layout, and every icon now points at it with <code>&lt;use&gt;</code>:</p>
<pre><code class="language-tsx">const SYMBOLS = {
  github: { viewBox: '0 0 438.549 438.549', d: 'M409.132 114.573c…' },
} as const

function symbolIcon(id: keyof typeof SYMBOLS) {
  return function SymbolIcon(props: React.SVGProps&lt;SVGSVGElement&gt;) {
    return (
      &lt;svg viewBox={SYMBOLS[id].viewBox} fill="currentColor" {...props}&gt;
        &lt;use href={`#icon-${id}`} /&gt;
      &lt;/svg&gt;
    )
  }
}

export function IconSprite() {
  return (
    &lt;svg aria-hidden="true" width="0" height="0" style={{ position: 'absolute', overflow: 'hidden' }}&gt;
      {Object.entries(SYMBOLS).map(([id, { viewBox, d }]) =&gt; (
        &lt;symbol key={id} id={`icon-${id}`} viewBox={viewBox}&gt;
          &lt;path fill="currentColor" d={d} /&gt;
        &lt;/symbol&gt;
      ))}
    &lt;/svg&gt;
  )
}
</code></pre>
<p><code>&lt;IconSprite /&gt;</code> goes once in <code>app/layout.tsx</code>. Each use is now under 200 bytes instead of 2.1 KB, and <code>currentColor</code> still follows the link's text colour. <code>/projects</code> went from 854 KB to 676 KB.</p>
<p>The sprite is not free. It adds about 3 KB to every page, so a page with one GitHub link got slightly heavier. That trade only pays off for icons that repeat. A single logo stays inline.</p>
<h2>Fix 2: JSON-LD on a list page should link, not copy</h2>
<p>The page's <code>ItemList</code> structured data held a full <code>SoftwareApplication</code> for each of the 49 projects: name, description, image, author, operating system. Each project's own page already publishes that same entity. The list was 40 KB of JSON-LD, shipped twice.</p>
<p>Google's <a href="https://developers.google.com/search/docs/appearance/structured-data/carousel">carousel documentation</a> describes this case as a summary page: list items that point at detail pages carry only <code>position</code> and <code>url</code>. The helper now returns the short form whenever a detail page exists:</p>
<pre><code class="language-ts">export function projectListItem(position: number, detailUrl: string | undefined, item: object) {
  return detailUrl
    ? { '@type': 'ListItem', position, url: detailUrl }
    : { '@type': 'ListItem', position, item }
}
</code></pre>
<p>The JSON-LD went from 40 KB to 10 KB, and no entity is described in two places that could drift apart.</p>
<h2>Fix 3: name the class strings every row repeats</h2>
<p>Each row carried long Tailwind strings for its grid, thumbnail, title, counter, tag and links, and they shipped twice like everything else. In Tailwind v4 a string that repeats down a long list can become a named utility, variants included:</p>
<pre><code class="language-css">@utility feed-row {
  @apply relative flex flex-col gap-3 border-b border-slate-300/10 py-6 last:border-none
    sm:grid sm:grid-cols-[200px_1fr] sm:gap-6 sm:py-7 lg:grid-cols-[240px_1fr];
}
</code></pre>
<p>One catch: if the element's classes go through <code>cn()</code> (tailwind-merge), keep colours out of the utility. tailwind-merge can't see inside <code>feed-link</code>, so it won't know that a caller's <code>text-teal-300</code> should replace the colour inside it. My <code>feed-link</code> holds layout only, and the colours stay on the element.</p>
<p>To prove nothing changed visually, I compared computed styles for every row part between a local <code>next start</code> build and production. They matched except for a rounding difference in one oklab channel.</p>
<p>Fixes 2 and 3 together took the page from 658 KB to 459 KB. (A rebuild between the two passes measured 658 KB, not the 676 KB from the sprite step.)</p>
<h2>Fix 4: prefetch links on intent, not on scroll</h2>
<p>This one doesn't change the HTML. Next.js prefetches every <code>&lt;Link&gt;</code> as it enters the viewport. On a page with a link per row, one visit to <code>/projects</code> fired 36 route prefetches, about 1 MB decoded, and on Vercel each one is a CDN request plus ISR reads.</p>
<p>The Next.js docs suggest a hover-only pattern for this. I added focus and touchstart so keyboard and phone users still get a head start:</p>
<pre><code class="language-tsx">'use client'
import Link from 'next/link'
import { useState } from 'react'

export function IntentLink(props: React.ComponentProps&lt;typeof Link&gt;) {
  const [intent, setIntent] = useState(false)
  return (
    &lt;Link
      {...props}
      prefetch={intent ? null : false}
      onMouseEnter={() =&gt; setIntent(true)}
      onFocus={() =&gt; setIntent(true)}
      onTouchStart={() =&gt; setIntent(true)}
    /&gt;
  )
}
</code></pre>
<p><code>null</code> is the default prefetch behaviour; <code>false</code> turns it off until there is intent. The <code>&lt;a href&gt;</code> in the HTML is identical, so crawlers see the same links.</p>
<h2>How do I keep the page from growing back?</h2>
<p>Set a budget that fails. My production check reads every URL in the sitemap and fails any page over 520 KB of uncompressed HTML:</p>
<pre><code class="language-ts">const HTML_BUDGET_KB = 520
const kb = Math.round(html.length / 1024)
if (kb &gt; HTML_BUDGET_KB) fail(`${url}: ${kb} KB of HTML, budget ${HTML_BUDGET_KB} KB`)
</code></pre>
<p>And pin each fix with a test. The sprite test renders each repeated icon and fails if one use is over 200 bytes or doesn't reference its symbol.</p>
<p>The budget alone wasn't enough. Writing this article, I ran the weighing script again and found a link arrow drawn inline 82 times on the page, about 11 KB, still under the budget and so never flagged. It is a symbol now too, and the page is 448 KB. So I re-run the weigh script whenever a list page changes, not only when the budget fails.</p>
]]></content:encoded></item><item><title><![CDATA[phBot xControl commands: run a whole party of bots from in-game chat]]></title><description><![CDATA[Every xControl chat command for phBot, with arguments and defaults from the source: START, FOLLOW, TP, EQUIP, CAPE, REVERSE and walk-script commands.]]></description><link>https://mohamedgado.hashnode.dev/phbot-xcontrol-commands</link><guid isPermaLink="true">https://mohamedgado.hashnode.dev/phbot-xcontrol-commands</guid><category><![CDATA[Python]]></category><category><![CDATA[gaming]]></category><category><![CDATA[automation]]></category><category><![CDATA[Game Development]]></category><dc:creator><![CDATA[Mohamed Gado]]></dc:creator><pubDate>Wed, 11 Nov 2026 13:50:27 GMT</pubDate><content:encoded><![CDATA[<p>xControl is a phBot plugin that turns chat into a remote control. You run it on every bot in a party, add one or more leader names to its list, and from then on any message a leader sends that matches a command is carried out by every bot that hears it. Type <code>START</code> in party chat and the whole party starts botting. Whisper <code>FOLLOW</code> to one character and only that one follows you.</p>
<p>It is one of the <a href="https://www.mohamedgado.com/projects/phbot-plugins">ten phBot plugins I maintain</a>. The original was written by JellyBitz; my version adds commands, walk-script commands and a new tab. Every argument and default below is read from the source of xControl 1.9.1, not from memory.</p>
<h2>How do I set up xControl?</h2>
<ol>
<li>Download <code>xControl.py</code> from the <a href="https://github.com/Vette1123/phbot-plugins">plugin repo</a> and put it in the <code>Plugins</code> folder inside your phBot folder.</li>
<li>Restart phBot. The plugin gets its own tab, named xControl.</li>
<li>On each bot, type the leader's character name into the box under <strong>Party Leaders</strong> and press <strong>Add</strong>. The list is saved per character, so you do this once.</li>
<li>Log the leader in and type a command in chat.</li>
</ol>
<p>Three rules decide whether a command runs:</p>
<ul>
<li><strong>Only leaders are obeyed.</strong> A message from anyone not on that bot's leader list is ignored, whoever sends it.</li>
<li><strong>Commands are uppercase.</strong> The plugin compares text exactly, so <code>START</code> works and <code>start</code> does nothing.</li>
<li><strong>The chat channel picks who reacts.</strong> Party chat reaches every bot in the party. A private message reaches one bot. All chat reaches every bot close enough to see it.</li>
</ul>
<p>Arguments are separated by spaces. Below, <code>&lt;name&gt;</code> is required and <code>[name]</code> is optional.</p>
<h2>Which commands start, stop and move the bots?</h2>
<table>
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>START</code></td>
<td>Starts botting.</td>
</tr>
<tr>
<td><code>STOP</code></td>
<td>Stops botting.</td>
</tr>
<tr>
<td><code>TRACE [player]</code></td>
<td>Starts phBot's trace on a player. With no name it traces whoever sent the command.</td>
</tr>
<tr>
<td><code>NOTRACE</code></td>
<td>Stops tracing.</td>
</tr>
<tr>
<td><code>FOLLOW [player] [distance]</code></td>
<td>Follows a player at a distance. Defaults: the sender, at 10. <code>FOLLOW 5</code> follows you at 5; <code>FOLLOW Gado 3</code> follows Gado at 3.</td>
</tr>
<tr>
<td><code>NOFOLLOW</code></td>
<td>Stops following.</td>
</tr>
<tr>
<td><code>MOVEON [radius]</code></td>
<td>Walks to a random point within the radius, default 10. Handy for unsticking a bot.</td>
</tr>
<tr>
<td><code>SIT</code></td>
<td>Sits down or stands up.</td>
</tr>
<tr>
<td><code>JUMP</code></td>
<td>Plays the jump emote.</td>
</tr>
<tr>
<td><code>DC</code></td>
<td>Disconnects the bot.</td>
</tr>
</tbody></table>
<h2>How do I change where the bots train?</h2>
<table>
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>SETPOS</code></td>
<td>Sets the training position to where the bot stands now.</td>
</tr>
<tr>
<td><code>SETPOS &lt;x&gt; &lt;y&gt; [region] [z]</code></td>
<td>Sets the training position to those coordinates.</td>
</tr>
<tr>
<td><code>GETPOS</code></td>
<td>The bot whispers its X, Y, Z and region back to you. Use it before <code>SETPOS</code> with coordinates.</td>
</tr>
<tr>
<td><code>SETRADIUS [radius]</code></td>
<td>Sets the training radius. With no number it resets to 35. A negative number is made positive.</td>
</tr>
<tr>
<td><code>SETAREA &lt;name&gt;</code></td>
<td>Switches to a training area you saved in phBot, by name.</td>
</tr>
<tr>
<td><code>SETSCRIPT [path]</code></td>
<td>Sets the training walk script. With no path it clears the script.</td>
</tr>
<tr>
<td><code>PROFILE [name]</code></td>
<td>Loads a phBot profile. With no name it loads <code>Default</code>.</td>
</tr>
</tbody></table>
<h2>How do I teleport, return or revive the party?</h2>
<table>
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>TP &lt;source&gt; &lt;destination&gt;</code></td>
<td>Teleports through a teleporter, naming both ends. Separate them with a space or a comma.</td>
</tr>
<tr>
<td><code>TP &lt;destination&gt;</code></td>
<td>Looks for a nearby teleporter NPC that goes there and uses it.</td>
</tr>
<tr>
<td><code>RETURN</code></td>
<td>Uses a return scroll after a random 0.5 to 2 second delay, so the party does not leave in the same tick. A dead bot revives at its return point instead.</td>
</tr>
<tr>
<td><code>RECALL &lt;npc&gt;</code></td>
<td>Sets the return point at a town NPC standing nearby.</td>
</tr>
<tr>
<td><code>R &lt;player&gt;</code></td>
<td>Uses a reverse return scroll to that player.</td>
</tr>
<tr>
<td><code>REVERSE return</code></td>
<td>Reverse return scroll to the last return point.</td>
</tr>
<tr>
<td><code>REVERSE death</code></td>
<td>Reverse return scroll to where the bot last died.</td>
</tr>
<tr>
<td><code>REVERSE player &lt;name&gt;</code></td>
<td>Reverse return scroll to a player. Same as <code>R</code>.</td>
</tr>
<tr>
<td><code>REVERSE zone &lt;name&gt;</code></td>
<td>Reverse return scroll to a zone.</td>
</tr>
</tbody></table>
<h2>Which commands help in PvP and job runs?</h2>
<table>
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>ZERK</code></td>
<td>Activates Berserk.</td>
</tr>
<tr>
<td><code>CAPE [color]</code></td>
<td>Puts on a PvP cape: <code>red</code>, <code>gray</code>, <code>blue</code>, <code>white</code> or <code>yellow</code>. <code>CAPE off</code> removes it. With no colour it picks yellow.</td>
</tr>
<tr>
<td><code>EQUIP &lt;item&gt;</code></td>
<td>Equips an item from the inventory whose name contains the text, ignoring case. <code>trader</code>, <code>hunter</code>, <code>thief</code> and <code>job</code> pick the matching job suit.</td>
</tr>
<tr>
<td><code>UNEQUIP &lt;item&gt;</code></td>
<td>Takes an equipped item off, matched the same way. <code>UNEQUIP job</code> removes the job suit.</td>
</tr>
<tr>
<td><code>MOUNT [pet]</code></td>
<td>Mounts a horse, or a summoned pet of that phBot pet type. Default: horse.</td>
</tr>
<tr>
<td><code>DISMOUNT [pet]</code></td>
<td>Dismounts, same defaults.</td>
</tr>
<tr>
<td><code>TERMINATE</code></td>
<td>Dismisses the summoned pet.</td>
</tr>
</tbody></table>
<p><code>EQUIP trader</code> followed by <code>START</code> on a caravan party is the whole job-suit swap in two messages.</p>
<h2>What about items, party and chat?</h2>
<table>
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>USE &lt;item&gt;</code></td>
<td>Uses an inventory item whose name contains the text. Unlike <code>EQUIP</code>, this match is case-sensitive, so type the name as the game spells it.</td>
</tr>
<tr>
<td><code>SORT</code></td>
<td>Sorts the inventory.</td>
</tr>
<tr>
<td><code>GETOUT</code></td>
<td>Leaves the party, if the bot is in one.</td>
</tr>
<tr>
<td><code>CHAT &lt;type&gt; &lt;message&gt;</code></td>
<td>Makes the bot speak. Types: <code>all</code>, <code>party</code>, <code>guild</code>, <code>union</code>, <code>stall</code>, <code>global</code>.</td>
</tr>
<tr>
<td><code>CHAT private &lt;player&gt; &lt;message&gt;</code></td>
<td>Sends a private message from the bot. <code>note</code> takes a player the same way and sends a note.</td>
</tr>
<tr>
<td><code>INJECT &lt;opcode&gt; [true|false] &lt;bytes...&gt;</code></td>
<td>Sends a raw packet to the server: the opcode in hex, an optional "encrypted" flag, then the data bytes in hex. For people who know the packet they want; <a href="https://www.mohamedgado.com/projects/phbot-plugins">xPacketTool</a> is the safer way to find one.</td>
</tr>
</tbody></table>
<h2>Can xControl run from a walk script?</h2>
<p>Yes. xControl adds five commands that phBot walk scripts (<code>.txt</code>) can call. They use commas, like every script command:</p>
<table>
<thead>
<tr>
<th>Script command</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>chat,&lt;type&gt;,&lt;message&gt;</code></td>
<td>Sends chat. <code>chat,private,&lt;name&gt;,&lt;message&gt;</code> and <code>chat,note,&lt;name&gt;,&lt;message&gt;</code> take a player name first.</td>
</tr>
<tr>
<td><code>pinvite,&lt;name&gt;[,&lt;name&gt;...]</code></td>
<td>Invites each named player standing nearby to the party, creating the party if there is none.</td>
</tr>
<tr>
<td><code>wait_party,&lt;count&gt;[,&lt;timeout ms&gt;]</code></td>
<td>Pauses the script until the party has at least that many members. The timeout defaults to 60 seconds.</td>
</tr>
<tr>
<td><code>use_scroll,&lt;keyword&gt;</code></td>
<td>Uses the first item whose name contains the keyword, ignoring case. If it is in a grab pet, it moves it to the inventory first. Example: <code>use_scroll,Random Awaken</code>.</td>
</tr>
<tr>
<td><code>leaveparty</code></td>
<td>Leaves the party, like <code>GETOUT</code>.</td>
</tr>
</tbody></table>
<p>A script that gathers a party before a run looks like this:</p>
<pre><code class="language-text">pinvite,Alpha,Bravo,Charlie
wait_party,4,90000
chat,party,START
</code></pre>
<p>The bot that runs it sends <code>START</code> in party chat, and every party bot with that character in its leader list starts.</p>
<h2>Why does a command do nothing?</h2>
<p>Check these in order:</p>
<ul>
<li><strong>The sender is not on that bot's leader list.</strong> The list belongs to each bot. Adding the leader on one character does not add it on the others.</li>
<li><strong>The command was typed in lowercase,</strong> or with extra text before it. The message has to start with the command.</li>
<li><strong>The bot cannot hear the channel.</strong> A private message reaches one bot. Party chat only reaches bots in your party.</li>
<li><strong>The item or NPC name does not match.</strong> <code>EQUIP</code> and <code>USE</code> search by part of the name; <code>RECALL</code> and <code>TP &lt;destination&gt;</code> need the NPC near the bot.</li>
</ul>
<p>The phBot log shows a line for most commands, including the failures (<code>Plugin: ...</code>), so read it first. If you are writing your own plugin on top of this, the <a href="https://www.mohamedgado.com/writing/phbot-plugin-python-api">phBot Python API guide</a> covers the <code>handle_chat</code> hook xControl is built on.</p>
]]></content:encoded></item><item><title><![CDATA[Next.js opengraph-image in Crawled – currently not indexed: should you fix it?]]></title><description><![CDATA[Search Console listed my Next.js opengraph-image routes as Crawled – currently not indexed. Why Google fetches them, and what noindex does.]]></description><link>https://mohamedgado.hashnode.dev/next-js-opengraph-image-in-crawled-currently-not-indexed-should-you-fix-it</link><guid isPermaLink="true">https://mohamedgado.hashnode.dev/next-js-opengraph-image-in-crawled-currently-not-indexed-should-you-fix-it</guid><category><![CDATA[Next.js]]></category><category><![CDATA[SEO]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Mohamed Gado]]></dc:creator><pubDate>Wed, 11 Nov 2026 12:03:46 GMT</pubDate><content:encoded><![CDATA[<p>Search Console's Page indexing report put 14 URLs from <a href="https://www.mohamedgado.com/projects/portfolio">this site</a> under "Crawled – currently not indexed". Only 4 of them were pages. The other 10 were files: six per-page <code>opengraph-image</code> routes, <code>feed.xml</code>, <code>favicon.ico</code>, and the root card twice, once with its cache-busting query. None of those should ever be in the index, so the status is harmless for them. I still added <code>X-Robots-Tag: noindex</code> to every one, but the more useful thing I learned is what that header does not do.</p>
<h2>Why does Google crawl opengraph-image at all?</h2>
<p>In the App Router, an <code>opengraph-image.tsx</code> file next to a page becomes a route that returns a PNG, and Next.js writes its URL into the page's head:</p>
<pre><code class="language-html">&lt;meta property="og:image" content="https://www.mohamedgado.com/projects/quizz-app/opengraph-image"&gt;
</code></pre>
<p>Googlebot follows URLs it finds, and this is a plain absolute URL on your own domain. It fetches the image, sees that it isn't a page, and doesn't index it as one. Search Console reports that outcome as "Crawled – currently not indexed". That label covers "we looked and passed", and for a social card that is the right result.</p>
<p>So if the only rows in that bucket are image routes, feeds and icons, nothing is broken. The rows worth reading are the HTML pages. For those, the status usually means Google judged the content thin or too close to another page. I wrote about that case in <a href="https://www.mohamedgado.com/writing/discovered-currently-not-indexed">Discovered – currently not indexed</a>.</p>
<h2>Why only some of my files had noindex</h2>
<p>I already had a rule from April that sent noindex on the root files:</p>
<pre><code class="language-js">// next.config.js, headers()
{
  source:
    '/:file(favicon\\.ico|icon\\.svg|apple-touch-icon\\.png|opengraph-image|twitter-image)',
  headers: [{ key: 'X-Robots-Tag', value: 'noindex' }],
}
</code></pre>
<p><code>/:file(...)</code> matches one path segment. <code>/opengraph-image</code> matches it, and <code>/projects/quizz-app/opengraph-image</code> doesn't, so every per-page card went out with no robots header at all:</p>
<pre><code class="language-text">$ curl -sI https://www.mohamedgado.com/projects/quizz-app/opengraph-image | grep -i x-robots
$ curl -sI https://www.mohamedgado.com/opengraph-image | grep -i x-robots
X-Robots-Tag: noindex
</code></pre>
<p>The fix is two more rules: one for the dynamic route folders, one for the feeds:</p>
<pre><code class="language-js">{
  source: '/:dir(projects|writing)/:slug/:file(opengraph-image|twitter-image)',
  headers: [{ key: 'X-Robots-Tag', value: 'noindex' }],
},
{
  source: '/:feed(feed\\.xml|writing/feed\\.xml)',
  headers: [{ key: 'X-Robots-Tag', value: 'noindex' }],
},
</code></pre>
<p>I named the two folders instead of writing <code>/:path*/opengraph-image</code>. A pattern that open is one typo away from matching a page, and a noindex header on a page is far worse than a missing one on a PNG.</p>
<h2>A test that runs real paths through the matcher</h2>
<p>Reading the config by eye is how the gap got in. The test compiles each rule's <code>source</code> with the same <code>path-to-regexp</code> build that Next.js ships, then checks paths in both directions:</p>
<pre><code class="language-ts">const { pathToRegexp } = load('next/dist/compiled/path-to-regexp')
const covered = (path: string) =&gt;
  noindex.some((r) =&gt; pathToRegexp(r.source, [], { strict: true, delimiter: '/' }).test(path))

for (const path of ['/projects/quizz-app/opengraph-image', '/writing/feed.xml' /* ... */])
  assert.ok(covered(path), `${path} is indexable`)
for (const path of ['/', '/projects/quizz-app', '/writing'])
  assert.ok(!covered(path), `${path} is a page but sends noindex`)
</code></pre>
<p>Before the fix it failed on <code>/projects/quizz-app/opengraph-image is indexable</code>. The second loop matters as much as the first. It is what stops a later, broader rule from quietly deindexing the pages.</p>
<h2>Does noindex clear the Search Console report?</h2>
<p>Not necessarily, and this is the part I didn't expect. My root <code>/opengraph-image</code> and <code>/favicon.ico</code> have sent noindex since April, and they were still listed under "Crawled – currently not indexed" in November. The report shows Google's last verdict on a URL. For a file it has already decided not to index, a new header may never change which bucket the row sits in.</p>
<p>So the header doesn't tidy the report. What it does:</p>
<ul>
<li>It makes the decision explicit instead of leaving it to Google's judgment, and it keeps the cards out of Google Images, where a text card on a dark background is worth nothing.</li>
<li>It doesn't block anyone. It isn't a <code>robots.txt</code> disallow, so Googlebot can still fetch the image, and so can Facebook, X, Slack and LinkedIn when they build a link preview. The card keeps working everywhere it is meant to.</li>
</ul>
<h2>What I'd do on a new Next.js site</h2>
<ol>
<li>Ignore image, feed and icon rows in "Crawled – currently not indexed". Read the HTML rows.</li>
<li>If you want the files marked, send <code>X-Robots-Tag: noindex</code> on every depth of <code>opengraph-image</code> and <code>twitter-image</code>, not only the root.</li>
<li>Never disallow those routes in <code>robots.txt</code>. A crawler that can't fetch the image can't show your card.</li>
<li>Pin the rule with a test that also asserts the pages are not matched.</li>
</ol>
<hr />
<p><em>Originally published at <a href="https://www.mohamedgado.com/writing/nextjs-opengraph-image-crawled-not-indexed">mohamedgado.com</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[phBot Telegram and Discord notifications: how my xNotify plugin works]]></title><description><![CDATA[xNotify sends phBot events to Telegram and Discord: how five clients send one alert, not five, and why Discord returned 403 at first.]]></description><link>https://mohamedgado.hashnode.dev/phbot-telegram-and-discord-notifications-how-my-xnotify-plugin-works</link><guid isPermaLink="true">https://mohamedgado.hashnode.dev/phbot-telegram-and-discord-notifications-how-my-xnotify-plugin-works</guid><category><![CDATA[Python]]></category><category><![CDATA[discord]]></category><category><![CDATA[telegram]]></category><dc:creator><![CDATA[Mohamed Gado]]></dc:creator><pubDate>Wed, 11 Nov 2026 12:03:02 GMT</pubDate><content:encoded><![CDATA[<p>xNotify is one of the <a href="https://www.mohamedgado.com/projects/phbot-plugins">phBot plugins</a> I wrote for Silkroad Online. It watches the game and sends a message to Telegram, Discord or both when something worth knowing happens: the character died, it ran out of potions, a unique spawned, someone sent a private message. The idea is simple. The hard parts were duplicates, spam and one confusing 403. If you have never written a plugin, start with <a href="https://www.mohamedgado.com/writing/phbot-plugin-python-api">the phBot Python API</a>; this post assumes you know what <code>handle_chat</code> and <code>event_loop</code> are.</p>
<h2>Five clients, one message</h2>
<p>Most people bot more than one character, and every phBot client loads its own copy of the plugin. A global chat line or a unique spawn notice reaches all of them at once. Without coordination, five clients means five identical Telegram messages.</p>
<p>The clients share nothing in memory, but they share a disk. Before sending a shared event, each copy tries to create the same marker file, named after a hash of the event and its text:</p>
<pre><code class="language-python">SHARED_EVENTS = {'global', 'notice', 'unique', 'all_chat'}
SHARED_WINDOW = {'global': 30.0, 'notice': 30.0, 'unique': 120.0, 'all_chat': 15.0}

def _claim_shared(key, window):
    h = hashlib.md5(key.encode('utf-8', 'replace')).hexdigest()
    path = os.path.join(_DEDUP_DIR, h)
    try:
        if time.time() - os.stat(path).st_mtime &lt; window:
            return False          # someone sent this inside the window
    except FileNotFoundError:
        pass
    try:
        fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
        os.close(fd)
        return True               # we created it, we send
    except FileExistsError:
        return False              # another client won the race
</code></pre>
<p><code>O_CREAT | O_EXCL</code> is the whole trick. The operating system creates the file only if it does not exist, and does so atomically, so exactly one client gets <code>True</code> even when all five arrive in the same millisecond. No lock server, no sockets between clients. (The real function also re-claims an expired marker by touching it, and returns <code>True</code> on any unexpected error, so a disk problem costs a duplicate, never a lost alert.)</p>
<p>The window is per event type. A unique spawn gets 120 seconds because the same notice repeats; all-chat gets 15 because people really do say the same thing twice. A cleanup pass deletes markers older than an hour, at most once every five minutes, so the folder never grows.</p>
<h2>A cooldown for everything else</h2>
<p>Per-character events, like a party invite or a trade request, don't need cross-client dedup, but they can still fire in bursts. Those go through a per-event cooldown on a monotonic clock, 60 seconds by default:</p>
<pre><code class="language-python">def _throttled(event_type):
    now = time.monotonic()
    with _throttle_lock:
        if now - _last_sent.get(event_type, 0) &lt; config.get('cooldown_sec', 60):
            return True
        _last_sent[event_type] = now
        return False
</code></pre>
<p><code>time.monotonic()</code> instead of <code>time.time()</code> means a clock change can't freeze or flood the alerts. Private messages pass <code>force=True</code> and skip the cooldown. Nobody wants the second PM from a friend held back for a minute.</p>
<h2>Fire on the edge, not the level</h2>
<p>"HP is 0" stays true for as long as the character lies dead. Checking it on every tick would send a message every tick until the cooldown caught it. The plugin keeps the previous value and fires only on the change from alive to dead:</p>
<pre><code class="language-python">if _prev_hp is not None and _prev_hp &gt; 0 and hp == 0:
    notify('death', 'Character died', extras=[('Last HP', _prev_hp)])
_prev_hp = hp
</code></pre>
<p>Potions work the same way: one message when the count drops from some to none, not one per check while the bag stays empty. The <code>is not None</code> guard matters on login, when there is no previous value yet and a fresh character at 0 potions shouldn't count as running out.</p>
<h2>Never send from the game thread</h2>
<p>phBot calls <code>handle_chat</code> and <code>event_loop</code> on its own thread. A Telegram request that takes three seconds there freezes the bot for three seconds. So <code>notify()</code> only puts a small dict on a <code>queue.Queue</code>, and one daemon thread does the network work, with three tries per channel and a short back-off between them:</p>
<pre><code class="language-python">def notify(event_type, text, force=False, extras=None):
    if not config.get('events', {}).get(event_type, False):
        return
    if event_type in SHARED_EVENTS:
        if not _claim_shared(event_type + '|' + (text or ''), SHARED_WINDOW.get(event_type, 30.0)):
            return
    elif not force and _throttled(event_type):
        return
    _send_q.put({'type': event_type, 'text': text or '', 'extras': extras or []})
</code></pre>
<p><code>notify</code> is also the public API. Another plugin can <code>import xNotify</code> and call <code>xNotify.notify('trade_start', 'Trade opened')</code> to reuse the same channels, dedup and cooldowns.</p>
<h2>Why Discord said 403 and Telegram didn't</h2>
<p>Telegram worked on the first try: a form POST to <code>https://api.telegram.org/bot&lt;token&gt;/sendMessage</code> with <code>chat_id</code> and <code>text</code>. The Discord webhook, with a correct URL, failed with <code>HTTP 403</code> and Cloudflare error code 1010.</p>
<p>Error 1010 means Cloudflare blocked the request based on the client's browser signature. Python's <code>urllib</code> sends <code>User-Agent: Python-urllib/3.x</code>, and Discord's edge rejects it. Setting any honest custom agent fixed it:</p>
<pre><code class="language-python">def _http_post(url, data_bytes, headers):
    # Cloudflare (Discord) blocks the default urllib User-Agent with 403/1010.
    headers = dict(headers)
    headers.setdefault('User-Agent', 'xNotify/%s (+phBot plugin)' % pVersion)
    req = urllib.request.Request(url, data=data_bytes, headers=headers, method='POST')
</code></pre>
<p>The plugin uses only the standard library because phBot's embedded Python makes installing <code>requests</code> awkward for users. If your own webhook calls from Python get a 403 with a correct URL, check the User-Agent before the token.</p>
<h2>Telling a real unique from a champion</h2>
<p>The first version flagged every champion and giant near the character as a unique, because it matched several rarity codes. phBot's mob <code>rarity</code> field uses 0 for general, 1 champion, 3 giant, 4 party champion, 5 elite and 6 unique. Only 6 is a real unique, so that is the default <code>unique_types</code>, with a name and keyword list as a fallback for server-specific bosses. The plugin README had said 8 until this week. The code was right, the docs weren't.</p>
<h2>What I'd keep for any game-bot notifier</h2>
<ol>
<li>Dedup across processes with an exclusive file create when the clients share a disk. It's simpler than any IPC.</li>
<li>Fire on transitions, not states.</li>
<li>Keep network calls off the thread that runs the game.</li>
<li>Set a User-Agent on every <code>urllib</code> request you send to a Cloudflare-fronted API.</li>
</ol>
<p>The code is in <a href="https://github.com/Vette1123/phbot-plugins">Vette1123/phbot-plugins</a>, along with nine other plugins.</p>
<hr />
<p><em>Originally published at <a href="https://www.mohamedgado.com/writing/phbot-telegram-discord-notifications">mohamedgado.com</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[HSTS preload on Vercel: why the apex redirect ignored my header]]></title><description><![CDATA[Next.js headers() set HSTS with includeSubDomains and preload, but the Vercel apex-to-www domain redirect still sent max-age only.]]></description><link>https://mohamedgado.hashnode.dev/hsts-preload-on-vercel-why-the-apex-redirect-ignored-my-header</link><guid isPermaLink="true">https://mohamedgado.hashnode.dev/hsts-preload-on-vercel-why-the-apex-redirect-ignored-my-header</guid><category><![CDATA[Vercel]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[Security]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Mohamed Gado]]></dc:creator><pubDate>Wed, 11 Nov 2026 11:21:56 GMT</pubDate><content:encoded><![CDATA[<p>I added <code>Strict-Transport-Security: max-age=63072000; includeSubDomains; preload</code> to <code>headers()</code> in <code>next.config.js</code> on <a href="https://www.mohamedgado.com/projects/portfolio">this site</a>, and www served it. The apex, <code>mohamedgado.com</code>, still answered with Vercel's default <code>max-age=63072000</code> and nothing else. hstspreload.org checks the apex, so the domain could not be submitted. The cause was the apex redirect. It was set in Vercel's Domains settings, and a domain-level redirect is answered before your project runs, so <code>headers()</code> never touches it. The fix was to move that redirect into <code>redirects()</code> in <code>next.config.js</code> and set the apex domain to serve Production.</p>
<h2>Why did <a href="http://mohamedgado.com">http://mohamedgado.com</a> take two redirects?</h2>
<p>This started as a crawl finding. Typing <code>http://mohamedgado.com</code> took two hops:</p>
<pre><code class="language-text">http://mohamedgado.com/   308 -&gt; https://mohamedgado.com/
https://mohamedgado.com/  308 -&gt; https://www.mohamedgado.com/
</code></pre>
<p>Vercel upgrades every HTTP request to HTTPS on the same host first, with a <code>308</code>. Their encryption docs say this redirect is an industry standard and cannot be disabled. Then the apex redirects to www. You can't merge the two hops, and you shouldn't try: the HSTS preload list requires that a site listening on port 80 redirects to HTTPS on the same host before it goes anywhere else.</p>
<p>What you can remove is the HTTP request itself. A browser that has the domain on its built-in HSTS preload list never sends <code>http://</code> at all. It rewrites the URL to <code>https://</code> before the first byte leaves the machine. The Vercel docs make the same point in their HSTS note: a site on the list is reached over HTTPS right away instead of following the redirect the CDN issues.</p>
<h2>What does Vercel send for HSTS by default?</h2>
<p>The docs give two defaults. The <code>.vercel.app</code> deployment URLs get the full preload header, and the whole <code>vercel.app</code> domain is already preloaded. Custom domains get HSTS for that one hostname only:</p>
<pre><code class="language-text">Strict-Transport-Security: max-age=63072000;
</code></pre>
<p>hstspreload.org rejects that. Its API returned two errors for my domain: <code>No includeSubDomains directive</code> and <code>No preload directive</code>. The docs also say you can change the header with custom response headers in your project, so I did.</p>
<h2>Setting the header in next.config.js</h2>
<p>The header goes in the rule that covers every route, next to the rest of the security headers:</p>
<pre><code class="language-js">// next.config.js
async headers() {
  return [
    {
      source: '/:path*',
      headers: [
        {
          key: 'Strict-Transport-Security',
          value: 'max-age=63072000; includeSubDomains; preload',
        },
        // Content-Security-Policy, X-Content-Type-Options, ...
      ],
    },
  ]
}
</code></pre>
<p>Before you add <code>includeSubDomains</code>, list every hostname in your DNS that serves web traffic. The directive tells browsers to refuse plain HTTP on all of them, including names you forgot about. I read the records at my registrar. There were five web hosts: the apex, www, and short subdomains for my apps <a href="https://www.mohamedgado.com/projects/masareef">Masareef</a>, <a href="https://www.mohamedgado.com/projects/rafiq">Rafiq</a> and <a href="https://www.mohamedgado.com/projects/nafis">Nafis</a>. All were on Vercel and all on HTTPS. The rest were TXT and MX records for email, which HSTS doesn't affect. One HTTP-only subdomain, such as an email click-tracking CNAME, would have broken the moment a browser saw the new header.</p>
<p>After the deploy, www was correct:</p>
<pre><code class="language-text">$ curl -sI https://www.mohamedgado.com | grep -i strict
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
$ curl -sI https://mohamedgado.com | grep -i strict
Strict-Transport-Security: max-age=63072000
</code></pre>
<p>The apex had not changed.</p>
<h2>Why the apex redirect ignored headers()</h2>
<p>In the Vercel dashboard, the apex was set to "Redirect to Another Domain", 308, to www. That is the setup Vercel suggests when you add a domain, and it works. But the redirect is answered at the domain level, before any request reaches your deployment's routing. Your <code>headers()</code>, <code>redirects()</code> and middleware never run for it, so the response carries Vercel's default HSTS and nothing from your config.</p>
<p>Preload checks the apex, because the list entry is the registrable domain. So the header has to be on the response the apex actually sends, and here that response is a redirect.</p>
<h2>Moving the apex redirect into next.config.js</h2>
<p>The fix is to let the deployment answer the apex and redirect it in code, where <code>headers()</code> applies. I added a host-guarded rule at the top of <code>redirects()</code>:</p>
<pre><code class="language-js">// next.config.js
async redirects() {
  return [
    {
      source: '/:path*',
      has: [{ type: 'host', value: 'mohamedgado.com' }],
      destination: 'https://www.mohamedgado.com/:path*',
      permanent: true, // 308, same as the domain-level redirect
    },
    // ...the rest
  ]
}
</code></pre>
<p>One thing to check before you trust that <code>has</code> rule: an unanchored pattern <code>mohamedgado.com</code> would also match <code>www.mohamedgado.com</code> and loop forever. Next.js compiles <code>has</code> values as <code>new RegExp(`^${value}$`)</code> (you can read it in <code>prepare-destination.js</code>), so the match is exact.</p>
<p>Then the order of operations matters:</p>
<ol>
<li>Deploy the code redirect first. It does nothing yet, because the domain-level redirect still answers the apex before the deployment sees it.</li>
<li>In Vercel, open Domains, edit the apex, and switch it from "Redirect to Another Domain" to "Connect to an environment: Production".</li>
</ol>
<p>Done in that order, the apex redirects to www the whole time. Done the other way round, there is a window where the apex serves the whole site as a duplicate of www.</p>
<h2>How do I check it worked?</h2>
<p>The apex now answers through the deployment, so it carries the full header and keeps the path and query:</p>
<pre><code class="language-text">$ curl -sI "https://mohamedgado.com/projects/masareef?x=1"
HTTP/1.1 308 Permanent Redirect
Location: https://www.mohamedgado.com/projects/masareef?x=1
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
</code></pre>
<p>hstspreload.org has an API that runs the same checks as its form:</p>
<pre><code class="language-text">$ curl -s "https://hstspreload.org/api/v2/preloadable?domain=mohamedgado.com"
{ "errors": [], "warnings": [] }
</code></pre>
<p>I also pinned both rules with tests that read <code>next.config.js</code>. One asserts that the every-route header has a <code>max-age</code> of at least a year plus <code>includeSubDomains</code> and <code>preload</code>. The other asserts that the apex has exactly one 308 to www. A later edit can't quietly drop either one.</p>
<h2>Should you submit to the preload list?</h2>
<p>Getting the domain eligible was the useful part. The header now reaches every host, and the apex takes one redirect, not two. Submitting is a separate decision, and for a portfolio I didn't submit.</p>
<p>The list does nothing for search rankings. Google already crawls the HTTPS URLs. A returning visitor's browser already remembers the HSTS header for two years after the first visit. Most browsers now try HTTPS first when someone types a bare domain. What preload adds is protection on the very first visit from someone who types <code>http://</code> on a network that strips TLS. The cost is a commitment: every future subdomain has to serve HTTPS, and getting off the list takes months.</p>
<p>If you run a login or payments on the domain, submit. If not, the header alone covers almost everything.</p>
]]></content:encoded></item><item><title><![CDATA[Clerk auth in Next.js: from authMiddleware to proxy.ts and auth.protect()]]></title><description><![CDATA[My 2023 Clerk demo guarded every route from authMiddleware. What replaced it in Next.js 16: clerkMiddleware() in proxy.ts and auth.protect() per page.]]></description><link>https://mohamedgado.hashnode.dev/clerk-nextjs-middleware-auth-protect</link><guid isPermaLink="true">https://mohamedgado.hashnode.dev/clerk-nextjs-middleware-auth-protect</guid><category><![CDATA[Next.js]]></category><category><![CDATA[#clerk]]></category><category><![CDATA[authentication]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[Mohamed Gado]]></dc:creator><pubDate>Wed, 11 Nov 2026 08:34:23 GMT</pubDate><content:encoded><![CDATA[<p>In 2023 I built <a href="https://www.mohamedgado.com/projects/next13-auth">a Next.js 13 dashboard behind Clerk</a>, and every route's protection lived in one file: <code>authMiddleware</code> in <code>middleware.ts</code>, deny by default. That code no longer runs on current versions. <code>authMiddleware</code> was replaced in <code>@clerk/nextjs</code> v5 by <code>clerkMiddleware()</code>, which protects nothing by default. Next.js 16 renamed <code>middleware.ts</code> to <code>proxy.ts</code>. And as of November 2026 Clerk deprecates gating routes in that file at all: each page, Route Handler and Server Function calls <code>await auth.protect()</code> itself.</p>
<p>This post walks the demo's real code from the 2023 version to the current one, and explains why the check moved.</p>
<h2>What did the 2023 middleware do?</h2>
<p>This is the whole of the demo's auth wiring, on <code>next</code> 13.4.7 and <code>@clerk/nextjs</code> 4.21:</p>
<pre><code class="language-ts">// middleware.ts
import { authMiddleware } from '@clerk/nextjs'

export default authMiddleware({
  publicRoutes: ['/api/:path*'],
})

export const config = {
  matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
}
</code></pre>
<p><code>authMiddleware</code> was deny by default. Every path the matcher covered needed a session unless <code>publicRoutes</code> named it. The dashboard at <code>/</code> and the profile page at <code>/profile</code> had no auth code of their own:</p>
<pre><code class="language-tsx">// app/(dashboard)/profile/page.tsx
import { UserProfile } from '@clerk/nextjs'

export default function ProfilePage() {
  return (
    &lt;main className='flex space-y-4 p-4'&gt;
      &lt;UserProfile /&gt;
    &lt;/main&gt;
  )
}
</code></pre>
<p>The sign-in and sign-up pages are optional catch-all routes, <code>app/(auth)/(routes)/login/[[...sign-in]]/page.tsx</code> and <code>register/[[...sign-up]]</code>, each rendering Clerk's <code>&lt;SignIn /&gt;</code> or <code>&lt;SignUp /&gt;</code>. Two environment variables point Clerk at them:</p>
<pre><code class="language-bash">NEXT_PUBLIC_CLERK_SIGN_IN_URL=/login
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/register
</code></pre>
<p>That part has not changed. The catch-all segment is still how Clerk's components handle their own sub-steps (verification, factor-two) under one route.</p>
<h2>Where did that setup leak?</h2>
<p>Deny by default felt safe, and it mostly was. Two holes were sitting in that file anyway.</p>
<p><strong>The matcher skipped any path with a dot in it.</strong> <code>/((?!.*\\..*|_next).*)</code> excludes a path when anything after the first slash contains a <code>.</code>, which was meant for <code>favicon.ico</code> and image files. A dynamic page like <code>/u/[handle]</code> visited as <code>/u/john.doe</code> also contains a dot, so the middleware never ran and the page rendered for anyone. Nothing in the demo had such a route, but nothing stopped the next one from being added. Clerk's current matcher skips only paths ending in a known static extension (<code>css</code>, <code>js</code>, <code>png</code>, <code>woff2</code> and so on).</p>
<p><strong><code>publicRoutes: ['/api/:path*']</code> made every API route public.</strong> That was deliberate, so webhooks could reach the app. But any Route Handler added later under <code>/api</code> that read user data had to check the session itself, and nothing reminded anyone to.</p>
<p>Both holes have the same shape. The protection was a list of URL patterns in one file, and the code it protected lived somewhere else. Clerk's migration guide names a third case that a path list cannot cover: Server Functions (including Server Actions) are called by ID, not by path. A Server Action defined in a protected folder and called from a public page arrives at the middleware as a request for the public page.</p>
<h2>What replaced authMiddleware?</h2>
<p><code>@clerk/nextjs</code> v5 introduced <code>clerkMiddleware()</code>, imported from <code>@clerk/nextjs/server</code>. Clerk's upgrade guide states the primary change: it "does not protect any routes by default". A straight port of the demo looked like this, using <code>createRouteMatcher()</code> to list the protected paths:</p>
<pre><code class="language-ts">// middleware.ts, @clerk/nextjs v5 and v6
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

const isProtected = createRouteMatcher(['/', '/profile(.*)'])

export default clerkMiddleware(async (auth, req) =&gt; {
  if (isProtected(req)) await auth.protect()
})
</code></pre>
<p>Note the inversion. The 2023 file listed public routes; this one lists protected routes. A new page that nobody adds to the list ships public, which is the failure deny by default was meant to prevent.</p>
<h2>How should a Clerk app protect routes on Next.js 16?</h2>
<p>Clerk's current docs (last updated 10 November 2026) deprecate <code>createRouteMatcher()</code>. It still works and logs a one-time warning in development, and it will be removed in the next major version of <code>@clerk/nextjs</code>. The recommendation is to protect each server-side resource where it runs.</p>
<p>Next.js 16 renamed Middleware to Proxy, so the file is <code>proxy.ts</code>. The behaviour is the same and only the name changed. It keeps <code>clerkMiddleware()</code> with no auth logic, because Clerk still needs it to read the session:</p>
<pre><code class="language-ts">// proxy.ts (middleware.ts on Next.js 15 and earlier)
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware()

export const config = {
  matcher: [
    // Skip Next.js internals and static files, unless found in search params
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    // Always run for API routes
    '/(api|trpc)(.*)',
    // Clerk's frontend API proxy path
    '/__clerk/(.*)',
  ],
}
</code></pre>
<p>The check moves into the page:</p>
<pre><code class="language-tsx">// app/(dashboard)/profile/page.tsx
import { UserProfile } from '@clerk/nextjs'
import { auth } from '@clerk/nextjs/server'

export default async function ProfilePage() {
  await auth.protect()

  return (
    &lt;main className='flex space-y-4 p-4'&gt;
      &lt;UserProfile /&gt;
    &lt;/main&gt;
  )
}
</code></pre>
<p>Route Handlers and Server Functions get the same line at the top. <code>auth.protect()</code> answers per request type: a signed-out page request is redirected to the sign-in URL, a Server Action gets a 401, and any other non-document request, such as a Route Handler, gets a 404.</p>
<p>Two details catch people on the way.</p>
<ul>
<li><strong>Put the check in the page, not only the layout.</strong> A layout does not re-render on client-side navigation between its children, and Route Handlers and Server Functions never pass through it. The page is where the data is read, so that is where the check belongs.</li>
<li><strong>Add a <code>loading.tsx</code> next to protected pages.</strong> With Cache Components, which new Next.js apps enable by default, a page that reads request data such as the session needs a Suspense boundary, and <code>loading.tsx</code> provides one. It changes the status code: a signed-out request then gets HTTP 200 with the redirect streamed in, instead of a 307. I hit the same streaming rule from the other side when <a href="https://www.mohamedgado.com/writing/nextjs-soft-404-loading-tsx"><code>notFound()</code> returned 200 behind loading.tsx</a>.</li>
</ul>
<h2>Can I keep the redirect in proxy.ts?</h2>
<p>Yes, as a speed-up and not as the guard. Clerk's guide allows an early redirect for signed-out users after every resource has its own check:</p>
<pre><code class="language-ts">// proxy.ts
import { clerkMiddleware } from '@clerk/nextjs/server'

export default clerkMiddleware(async (auth, req) =&gt; {
  // Not an auth guarantee: it only saves signed-out users a round trip.
  const { pathname } = req.nextUrl
  if (pathname === '/profile' || pathname.startsWith('/profile/')) {
    const { isAuthenticated, redirectToSignIn } = await auth()
    if (!isAuthenticated) return redirectToSignIn()
  }
})
</code></pre>
<p>To check that the early redirect is not doing the real work, delete it and test signed out. Every protected page should still redirect, and every protected Route Handler should still refuse. If deleting a line in <code>proxy.ts</code> exposes data, that line was the guard and belongs in the resource.</p>
<p>Clerk also ships <code>@clerk/eslint-plugin</code> with a <code>require-auth-protection</code> rule. It flags App Router pages, Route Handlers and Server Functions under folders you mark protected that never call an auth check. That restores what deny by default gave the 2023 demo: a new page that forgets its check fails lint instead of shipping public.</p>
<h2>The migration in four steps</h2>
<ol>
<li>Upgrade <code>@clerk/nextjs</code> and replace <code>authMiddleware</code> from <code>@clerk/nextjs</code> with <code>clerkMiddleware</code> from <code>@clerk/nextjs/server</code>.</li>
<li>On Next.js 16, rename <code>middleware.ts</code> to <code>proxy.ts</code> and replace the matcher with Clerk's current one.</li>
<li>Add <code>await auth.protect()</code> to every page, Route Handler and Server Function that reads user data, with a <code>loading.tsx</code> beside protected pages.</li>
<li>Remove the path checks from <code>proxy.ts</code>, test the app signed out, and turn on the lint rule so new routes cannot skip the check.</li>
</ol>
<p>The <a href="https://www.mohamedgado.com/projects/next13-auth">demo's source and live dashboard</a> still show the 2023 wiring, which is the starting point for step one.</p>
]]></content:encoded></item><item><title><![CDATA[How to write a phBot plugin: the Python API, hooks and a working example]]></title><description><![CDATA[phBot plugins are one Python file in the Plugins folder. The hooks phBot calls, the API calls my ten plugins use, and a small plugin you can run.]]></description><link>https://mohamedgado.hashnode.dev/phbot-plugin-python-api</link><guid isPermaLink="true">https://mohamedgado.hashnode.dev/phbot-plugin-python-api</guid><category><![CDATA[Python]]></category><category><![CDATA[GameDev]]></category><category><![CDATA[plugins]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Mohamed Gado]]></dc:creator><pubDate>Wed, 11 Nov 2026 07:51:25 GMT</pubDate><content:encoded><![CDATA[<p>A phBot plugin is a single Python file dropped into phBot's <code>Plugins/</code> folder. phBot imports it at startup. Your plugin talks to the bot through two modules, <code>phBot</code> and <code>QtBind</code>, and phBot calls functions in your file by name when something happens: a packet arrives, a chat message lands, half a second passes. That is the whole model. Everything below comes from the <a href="https://www.mohamedgado.com/projects/phbot-plugins">ten phBot plugins I maintain</a>, so each call is one I have seen work in a live client.</p>
<h2>What does a minimal phBot plugin look like?</h2>
<p>Here is a complete plugin. It adds a tab with a checkbox, and when it is ticked, it answers "ping" in party chat with "pong".</p>
<pre><code class="language-python">from phBot import *
import QtBind

pName = 'xHello'
pVersion = '1.0.0'

gui = QtBind.init(__name__, pName)
QtBind.createLabel(gui, 'Replies "pong" when a party member types "ping".', 10, 10)
cbxEnabled = QtBind.createCheckBox(gui, 'cbxEnabled_clicked', 'Enabled', 10, 40)
enabled = False

def cbxEnabled_clicked(checked):
    global enabled
    enabled = checked

def handle_chat(t, player, msg):
    if enabled and t == 4 and msg.strip().lower() == 'ping':
        phBotChat.Party('pong')

log('Plugin: ' + pName + ' v' + pVersion + ' loaded')
</code></pre>
<p>Save it as <code>xHello.py</code> in <code>Plugins/</code>, then restart phBot or use <strong>Reload Plugins</strong>. A new <strong>xHello</strong> tab appears.</p>
<p>Three things in it apply to every plugin:</p>
<ul>
<li><code>pName</code> and <code>pVersion</code> are module-level names. My plugins also set <code>pAuthor</code> and <code>pUrl</code>, the raw GitHub URL of the file.</li>
<li><code>QtBind.init(__name__, pName)</code> creates the plugin's tab and returns a handle. Every UI call takes that handle first.</li>
<li>Callbacks are passed by <strong>name, as a string</strong>: <code>'cbxEnabled_clicked'</code>. phBot looks the function up in your module when the checkbox changes, so the name has to match exactly.</li>
</ul>
<h2>Which functions does phBot call in a plugin?</h2>
<p>You opt in to an event by defining a function with the right name. These are the ones my plugins use:</p>
<table>
<thead>
<tr>
<th>Function</th>
<th>When phBot calls it</th>
</tr>
</thead>
<tbody><tr>
<td><code>handle_joymax(opcode, data)</code></td>
<td>Every packet from the game server. Return <code>True</code> to pass it on to the client, <code>False</code> to drop it.</td>
</tr>
<tr>
<td><code>handle_silkroad(opcode, data)</code></td>
<td>Every packet from the game client. Same return rule, towards the server.</td>
</tr>
<tr>
<td><code>handle_chat(t, player, msg)</code></td>
<td>Every chat message. <code>t</code> is the chat type.</td>
</tr>
<tr>
<td><code>handle_event(t, data)</code></td>
<td>Bot events with a text payload. My Jupiter hunter reads it for the kill-credit message that names a unique.</td>
</tr>
<tr>
<td><code>event_loop()</code></td>
<td>Every 500 ms, as long as the plugin is loaded.</td>
</tr>
<tr>
<td><code>joined_game()</code></td>
<td>Once the character is in the game world.</td>
</tr>
<tr>
<td><code>connected()</code> / <code>disconnected()</code></td>
<td>When the connection to the server opens or drops.</td>
</tr>
<tr>
<td><code>teleported()</code></td>
<td>After every teleport.</td>
</tr>
</tbody></table>
<p>The return value of the two packet hooks is easy to get wrong. Every hook in my plugins ends with an explicit <code>return True</code>; a hook that falls off the end returns <code>None</code>, and you should not have to find out what phBot does with that.</p>
<p>The chat types in <code>handle_chat</code> are numbers. xControl maps them like this: 1 all, 2 private, 3 guild, 4 party, 5 global, 6 note, 7 stall, 11 union. Union messages arrive with the guild name in front, so xControl strips everything up to the first <code>": "</code> before parsing.</p>
<h2>How do I read the character and the world?</h2>
<p>The <code>phBot</code> module exposes getters that return plain dicts and lists:</p>
<ul>
<li><code>get_character_data()</code>: name, server, region, position, HP, MP and more. My xNotify plugin reads <code>hp</code>, <code>mp</code>, <code>name</code>, <code>region</code> and <code>x</code>/<code>y</code> from it.</li>
<li><code>get_inventory()</code>, <code>get_pets()</code>, <code>get_pet_inventory()</code> and <code>get_item(model_id)</code> for items.</li>
<li><code>get_party()</code>, <code>get_monsters()</code> and <code>get_position()</code> for the world around you.</li>
<li><code>get_config_dir()</code>: the folder where phBot keeps its configs. Plugins store their own JSON there, for example <code>get_config_dir() + pName + '.json'</code>.</li>
</ul>
<p>Treat every getter as able to return nothing. Right after login, or during a teleport, <code>get_character_data()</code> can be empty, so xNotify reads it as <code>get_character_data() or {}</code> inside a <code>try</code>.</p>
<h2>How do I make the bot do something?</h2>
<ul>
<li><code>log(text)</code> writes to phBot's log tab. It is the first debugging tool you have.</li>
<li><code>start_bot()</code> and <code>stop_bot()</code> toggle botting. <code>set_training_position(region, x, y, z)</code> moves the training area, and <code>set_training_script(path)</code> swaps the walk script.</li>
<li><code>move_to(x, y, z)</code> walks the character. xControl's follow command calls it every loop tick.</li>
<li><code>phBotChat.All</code>, <code>.Party</code>, <code>.Guild</code>, <code>.Union</code>, <code>.Global</code> and <code>.Stall</code> send chat. <code>phBotChat.Private(name, msg)</code> and <code>phBotChat.Note(name, msg)</code> take a recipient first.</li>
<li><code>inject_joymax(opcode, data, encrypted)</code> sends a raw packet to the server, and <code>inject_silkroad</code> sends one to the client. <code>data</code> is a <code>bytearray</code>.</li>
</ul>
<p>Packet injection is how most of the interesting plugins work. My Magic Pop spinner, the lightstone crafter and the Jupiter hunter all build packets by hand and send them with <code>inject_joymax</code>. To find the opcodes in the first place, I use xPacketTool, which logs client and server packets with an opcode filter.</p>
<h2>How do I run plugin code from a walk script?</h2>
<p>Any plugin function that takes one <code>args</code> list can be called from a phBot training script by its name. xPacketTool defines <code>inject(args)</code>, so a script line like this greets:</p>
<pre><code class="language-text">inject,3091,0
</code></pre>
<p>phBot splits the line on commas and passes the list, so <code>args[0]</code> is the command name itself, and the values start at <code>args[1]</code>. The function returns <code>0</code>, as every script command in my plugins does. xControl uses the same mechanism for its <code>chat,party,&lt;message&gt;</code> command.</p>
<h2>What goes wrong most often?</h2>
<ul>
<li><strong>Blocking the loop.</strong> <code>event_loop</code> runs on phBot's own thread. A <code>time.sleep</code> or a slow HTTP call there freezes the bot. xNotify, which sends Telegram and Discord alerts, queues them for a background <code>threading.Thread</code> instead.</li>
<li><strong>Reacting to your own messages.</strong> <code>handle_chat</code> also fires for what your character says. xControl remembers each message it sent and skips it once when it comes back.</li>
<li><strong>UI method names that differ between builds.</strong> Some <code>QtBind</code> methods are not present on every phBot build. My plugins look a method up with <code>getattr</code> and skip it when it is missing, rather than crashing the whole plugin on load.</li>
</ul>
<p>The full plugins, including xControl's party commands and the Caravan runner with its own dashboard, are on the <a href="https://www.mohamedgado.com/projects/phbot-plugins">phBot plugins page</a>, with the source on <a href="https://github.com/Vette1123/phbot-plugins">GitHub</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Lemon Squeezy subscription statuses: who should keep access, and the two bugs I shipped]]></title><description><![CDATA[Expired subscribers kept Pro forever and a failed payment cut access. Which Lemon Squeezy statuses grant access, and the one function that decides it.]]></description><link>https://mohamedgado.hashnode.dev/lemon-squeezy-subscription-status-access</link><guid isPermaLink="true">https://mohamedgado.hashnode.dev/lemon-squeezy-subscription-status-access</guid><category><![CDATA[SaaS]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Mohamed Gado]]></dc:creator><pubDate>Wed, 11 Nov 2026 07:48:52 GMT</pubDate><content:encoded><![CDATA[<p>Lemon Squeezy reports a subscription as one of seven statuses: <code>on_trial</code>, <code>active</code>, <code>past_due</code>, <code>unpaid</code>, <code>paused</code>, <code>cancelled</code> and <code>expired</code>. Three of them should keep a paying customer's access (<code>on_trial</code>, <code>active</code> and <code>past_due</code>), one keeps it until a date (<code>cancelled</code>, until <code>ends_at</code>), and the rest should not. In <a href="https://www.mohamedgado.com/projects/freeby">Freeby</a>, my invoicing app, I got two of these wrong at the same time. Expired subscribers kept unlimited Pro forever, and a customer whose card failed lost Pro on the first retry.</p>
<h2>What does each Lemon Squeezy status mean for access?</h2>
<table>
<thead>
<tr>
<th>Status</th>
<th>What happened</th>
<th>Access</th>
</tr>
</thead>
<tbody><tr>
<td><code>on_trial</code></td>
<td>Free trial running</td>
<td>Yes</td>
</tr>
<tr>
<td><code>active</code></td>
<td>Paid and renewing</td>
<td>Yes</td>
</tr>
<tr>
<td><code>past_due</code></td>
<td>A renewal payment failed and Lemon Squeezy is retrying it</td>
<td>Yes, as a grace period</td>
</tr>
<tr>
<td><code>unpaid</code></td>
<td>The retries ran out</td>
<td>No</td>
</tr>
<tr>
<td><code>paused</code></td>
<td>Billing paused</td>
<td>No</td>
</tr>
<tr>
<td><code>cancelled</code></td>
<td>The customer cancelled; the paid period has not ended</td>
<td>Yes, until <code>ends_at</code></td>
</tr>
<tr>
<td><code>expired</code></td>
<td>The paid period ended</td>
<td>No</td>
</tr>
</tbody></table>
<p><code>cancelled</code> is the one people misread. It does not mean "gone". The customer has paid to the end of the period, and Lemon Squeezy moves the subscription to <code>expired</code> when that period ends. Cutting access on the <code>subscription_cancelled</code> webhook takes away days the customer paid for.</p>
<h2>How did expired subscribers keep Pro?</h2>
<p>Freeby's webhook stored two fields per subscription: the mapped <code>status</code>, and a <code>plan</code>. The plan was derived like this:</p>
<pre><code class="language-ts">const plan: 'pro' | 'free' = status === 'free' ? 'free' : 'pro'
</code></pre>
<p>Every paid status, <code>expired</code> included, stored <code>plan: "pro"</code>. That alone is fine, since the row records which product was bought. The bug was on the reading side. The client limit and the monthly invoice limit both looked only at the plan:</p>
<pre><code class="language-ts">const limit = LIMITS[entitlement.plan].maxInvoicesPerMonth
</code></pre>
<p><code>entitlement.plan</code> came straight from the row, so an expired subscriber had Infinity invoices and Infinity clients, and always would. The entitlement also had an <code>isActive</code> flag computed from the status, but only the email-sending check used it. Two checks used one field, one check used the other, and nothing forced them to agree.</p>
<h2>How did one failed payment cut access?</h2>
<p>The status mapping and the access list disagreed about <code>past_due</code>:</p>
<pre><code class="language-ts">// mapLsStatus: the webhook side
case 'past_due':
  return { status: 'past_due', isActive: true } // grace period

// getEntitlement: the read side
export const ACTIVE_STATUSES = ['active', 'on_trial']
</code></pre>
<p>The webhook mapper was written with a grace period in mind. The read side, which decides what the app actually does, left <code>past_due</code> out. A customer whose renewal failed for an ordinary reason, such as an expired card, lost Pro while Lemon Squeezy was still retrying the charge.</p>
<h2>What is the fix?</h2>
<p>Make one pure function decide what a user gets right now, and make <code>plan</code> mean the <em>effective</em> plan, so the 11 places that read it are right without being touched:</p>
<pre><code class="language-ts">export const ACTIVE_STATUSES = ['active', 'on_trial', 'past_due']

export function deriveEntitlement(row, now: Date): Entitlement {
  const inPaidPeriod =
    row.status === 'canceled' &amp;&amp;
    row.currentPeriodEnd !== null &amp;&amp;
    row.currentPeriodEnd &gt; now
  const isActive =
    row.plan === 'pro' &amp;&amp; (ACTIVE_STATUSES.includes(row.status) || inPaidPeriod)
  return {
    plan: isActive ? 'pro' : 'free',
    status: row.status,
    isActive,
    cancelAtPeriodEnd: row.cancelAtPeriodEnd || inPaidPeriod,
    currentPeriodEnd: row.currentPeriodEnd,
  }
}
</code></pre>
<p>The database read calls it with <code>new Date()</code>. The tests call it with a fixed date, which is why <code>now</code> is a parameter instead of a <code>Date</code> inside the function.</p>
<h2>How do I stop the two lists drifting apart again?</h2>
<p>The <code>past_due</code> bug existed because the same fact lived in two places. A test now walks every Lemon Squeezy status through the webhook mapper and checks the answer against the access list:</p>
<pre><code class="language-ts">for (const ls of ['active', 'on_trial', 'past_due', 'unpaid', 'paused',
  'cancelled', 'cancelled_immediately', 'expired']) {
  const r = mapLsStatus(ls)
  expect(ACTIVE_STATUSES.includes(r.status)).toBe(r.isActive)
}
</code></pre>
<p>Run against the old list, it fails on <code>past_due</code>. Five more tests pin the effective plan: active Pro gets Pro limits, expired Pro gets Free limits, cancelled Pro keeps access until its period ends and loses it after, and paused Pro is Free.</p>
<h2>What should I check in my own billing code?</h2>
<ul>
<li>Find every place that gates a feature, and check whether it reads the stored plan or the current status. If both exist, one of them is wrong somewhere.</li>
<li>Treat <code>cancelled</code> as "still paid until <code>ends_at</code>", not as "gone".</li>
<li>Decide on <code>past_due</code> on purpose. A grace period is the usual choice, because most failed renewals are expired cards that the customer fixes.</li>
<li>Keep the status-to-access decision in one pure function with the clock passed in, so you can test the end of a billing period without waiting a month.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[React error #418: fixing a matchMedia hydration mismatch with useSyncExternalStore]]></title><description><![CDATA[Minified React error #418 on every page load came from reading window.matchMedia in a useState initializer. useSyncExternalStore fixed it.]]></description><link>https://mohamedgado.hashnode.dev/react-418-hydration-matchmedia</link><guid isPermaLink="true">https://mohamedgado.hashnode.dev/react-418-hydration-matchmedia</guid><category><![CDATA[React]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Mohamed Gado]]></dc:creator><pubDate>Wed, 11 Nov 2026 00:00:09 GMT</pubDate><content:encoded><![CDATA[<p>Minified React error #418 means the HTML the server rendered did not match what the client rendered on its first pass. On my Next.js site the cause was a <code>useMediaQuery</code> hook that read <code>window.matchMedia(query).matches</code> inside a <code>useState</code> initializer. The server returned <code>false</code>, a desktop browser returned <code>true</code>, and React threw away the server HTML. Rewriting the hook on <code>useSyncExternalStore</code> with a <code>getServerSnapshot</code> of <code>false</code> made the first client render match the server by construction.</p>
<h2>What does minified React error #418 mean?</h2>
<p>React 19 reports it as "Hydration failed because the server rendered HTML didn't match the client." During hydration React walks the server HTML and the first client render side by side. If one has a node the other lacks, React cannot reuse the markup. It discards the server tree and renders the whole app again on the client.</p>
<p>In production the message is minified, so all you get is <code>#418</code> and a link. It names no component and no node. On my site it fired on every production page load, and nothing looked broken, because the client re-render produced a correct page. The cost was invisible: the server HTML was thrown away, and every visitor paid for a full client render. I think this is what my earlier performance audit recorded as "mobile lab bound by react-dom hydration", but I have no before and after measurement to prove that link.</p>
<h2>Why does reading matchMedia in useState cause a hydration mismatch?</h2>
<p>The server has no <code>window</code>. A hook that wants to run on both sides has to pick a value for the server, and the natural choice is <code>false</code>. The mistake is reading the browser value during the first client render.</p>
<p>This is the shape of my broken hook. It is simplified, not the exact file I had:</p>
<pre><code class="language-ts">import { useEffect, useState } from 'react'

// Broken: the first client render reads the browser, the server cannot.
export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() =&gt;
    typeof window === 'undefined' ? false : window.matchMedia(query).matches
  )

  useEffect(() =&gt; {
    const mql = window.matchMedia(query)
    const onChange = () =&gt; setMatches(mql.matches)
    mql.addEventListener('change', onChange)
    return () =&gt; mql.removeEventListener('change', onChange)
  }, [query])

  return matches
}
</code></pre>
<p>On the server the initializer returns <code>false</code>. In a desktop browser the same initializer runs during the first client render and returns <code>true</code>. Hydration compares the two trees at that moment, so the value is already different before any effect runs.</p>
<p>The caller made it visible. My spotlight component draws a cursor-following overlay only when the device has a fine pointer:</p>
<pre><code class="language-tsx">const hasFinePointer = useMediaQuery('(pointer: fine)')

// ...
{hasFinePointer &amp;&amp; (
  &lt;div ref={overlay} className="pointer-events-none fixed inset-0 z-30 ..." /&gt;
)}
</code></pre>
<p>The server rendered no overlay. The desktop client rendered one extra child, so every sibling after it shifted position. That is enough for #418.</p>
<h2>How do I find which component mismatches?</h2>
<p>Production will not tell you. Run <code>pnpm dev</code> (or your package manager's dev command) and load the page. The development build of React prints the full, non-minified error, including a component stack with <code>+</code> and <code>-</code> lines that mark the exact mismatched node. The <code>+</code> side is what the client rendered and the <code>-</code> side is what the server sent. For my bug that diff pointed at the spotlight overlay <code>div</code>.</p>
<p>Two things to check once you have the component:</p>
<ol>
<li>Does it read <code>window</code>, <code>document</code>, <code>localStorage</code>, <code>matchMedia</code> or <code>Date.now()</code> while rendering, or in a <code>useState</code> initializer?</li>
<li>Does it branch the JSX on that value?</li>
</ol>
<p>If both are yes, you have found it. Other common causes of #418 are invalid HTML nesting and browser extensions that modify the DOM, but those did not apply here.</p>
<p>A second clue was sitting in my own code. A <code>mounted</code> guard already existed in a command palette trigger, with a comment explaining that the media query mismatched. The spotlight had no such guard. A per-caller workaround for a hook-level bug is the signal to fix the hook.</p>
<h2>How do I fix it with useSyncExternalStore?</h2>
<p><code>useSyncExternalStore</code> takes three arguments: <code>subscribe</code>, <code>getSnapshot</code> and <code>getServerSnapshot</code>. From the React docs, <code>getServerSnapshot</code> is used during server rendering and during hydration on the client. That is the property that matters here. React does not call your client <code>getSnapshot</code> for the hydrating render, so the first client render sees the same <code>false</code> the server saw. Right after hydration finishes, React reads the real snapshot and re-renders if it differs. The docs describe this behaviour, and I did not trace it through React's source.</p>
<p>This is the hook as it is now in my repo:</p>
<pre><code class="language-ts">import { useCallback, useMemo, useSyncExternalStore } from 'react'

export function useMediaQuery(query: string): boolean {
  // One MediaQueryList per query, reused by both subscribe and getSnapshot.
  const mql = useMemo(
    () =&gt; (typeof window === 'undefined' ? null : window.matchMedia(query)),
    [query]
  )

  const subscribe = useCallback(
    (onStoreChange: () =&gt; void) =&gt; {
      mql?.addEventListener('change', onStoreChange)
      return () =&gt; mql?.removeEventListener('change', onStoreChange)
    },
    [mql]
  )

  return useSyncExternalStore(
    subscribe,
    () =&gt; mql?.matches ?? false,
    () =&gt; false
  )
}
</code></pre>
<p>Three details in it are worth copying:</p>
<ul>
<li>The <code>MediaQueryList</code> is created once per query in <code>useMemo</code>. <code>getSnapshot</code> runs on every render, so calling <code>matchMedia</code> inside it would repeat work for no reason.</li>
<li><code>subscribe</code> is wrapped in <code>useCallback</code> keyed on the list. A new function identity each render would make React unsubscribe and resubscribe every time.</li>
<li>The server snapshot is a constant <code>false</code>. That is the contract for callers: the first paint always has <code>false</code>, and the real value arrives in the commit right after.</li>
</ul>
<h2>Do I still need a mounted guard?</h2>
<p>No. Once the hook returns <code>false</code> on the first client render, a <code>mounted</code> state flag adds nothing. I deleted the guard in the palette trigger. That component now hides itself on desktop with the Tailwind class <code>sm:hidden</code>, which is CSS and needs no JavaScript value at all.</p>
<p>This points at the cheaper option for many cases. If a media query only changes layout or visibility, use CSS. Keep <code>useMediaQuery</code> for the cases where JavaScript has to act on the result, such as attaching a mouse handler, as my spotlight does.</p>
<p>The trade-off is a one-frame flash. With a <code>false</code> server snapshot, a desktop user gets the touch layout first and the desktop layout after hydration. For my overlay that is invisible, since it is a faint gradient. For a layout that changes the page structure, pick a server default that suits your most common visitor, or use CSS.</p>
<h2>How do I confirm the fix worked?</h2>
<p>In production, the console on a fresh page load no longer shows #418; the only errors left there come from browser extensions. If it ever comes back, <code>pnpm dev</code> is where to look, since it prints the full hydration warning with its component stack.</p>
<p>One trap while checking: counting JSON-LD tags with a bare grep for <code>application/ld+json</code> double-counts, because the RSC payload embeds the component tree as an escaped string. Count real tags with <code>grep -o '&lt;script type="application/ld+json"'</code> instead, or <code>document.querySelectorAll</code> in the browser.</p>
<p>The rule I took from it: never read <code>window</code>, <code>matchMedia</code> or <code>localStorage</code> in a <code>useState</code> initializer in a component that renders on the server. Use <code>useSyncExternalStore</code> with a server snapshot, or use CSS.</p>
<p>The site this came from is described in the <a href="https://www.mohamedgado.com/projects/portfolio">portfolio case study</a>. Another rendering bug from the same site, a 200 status on unknown slugs, is covered in <a href="https://www.mohamedgado.com/writing/nextjs-soft-404-loading-tsx">Next.js soft 404: why notFound() returned 200 behind loading.tsx</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Why a CSS rotate with will-change: transform makes images and text blurry]]></title><description><![CDATA[My screenshots looked pixelated though the files were sharp. A 0.9deg rotate under will-change: transform was the cause. The CSS before and after.]]></description><link>https://mohamedgado.hashnode.dev/css-rotate-will-change-blurry</link><guid isPermaLink="true">https://mohamedgado.hashnode.dev/css-rotate-will-change-blurry</guid><category><![CDATA[CSS]]></category><dc:creator><![CDATA[Mohamed Gado]]></dc:creator><pubDate>Tue, 10 Nov 2026 23:57:25 GMT</pubDate><content:encoded><![CDATA[<p>If an element is rotated or scaled and has <code>will-change: transform</code>, the browser can rasterise it once and then resample that bitmap every time it moves. Text and thin UI edges come out soft, and no amount of image quality fixes it. On my site, app screenshots looked pixelated because every card had a 0.9deg rotation under <code>will-change: transform</code> and a hover scale. Removing the tilt, the scale and the <code>will-change</code> made them sharp.</p>
<p>The report that started this was short: the images on the Masareef, Rafiq and Nafis project pages looked pixelated and bad. The files on disk were fine. Every screenshot was 1200px wide and sharp. The browser was blurring them on the way to the screen.</p>
<h2>Why does will-change: transform make things blurry?</h2>
<p><code>will-change: transform</code> tells the browser to promote the element to its own compositor layer. That layer is drawn into a bitmap, and from then on the compositor moves, rotates and scales the bitmap on the GPU without repainting the content.</p>
<p>That is fast, and it is also the problem. A layer that is rasterised once, upright, has to be resampled to be turned. A bitmap that is resampled at an angle cannot keep a one-pixel edge crisp, and small text is made of one-pixel edges. My notes from the fix put it plainly: a rotated raster of small type is never sharp at any angle, and the angle only decides how much.</p>
<p>Scaling has the same cost. In my case the hover state scaled the card by 1.045 on that same layer. The layer was not re-rasterised at the new size, so the card under the cursor was an upscaled copy of its own bitmap. The one card the visitor was looking at was the softest on the page.</p>
<h2>How did I confirm the cause?</h2>
<p>I read computed style straight from production in the browser the report came from, a 1512px wide window at a device pixel ratio of 2. One evaluation returned three things: the transform matrix <code>matrix(0.999877, -0.0157…)</code>, <code>will-change: transform</code>, and an image request for <code>w=640&amp;q=75</code>. That was the diagnosis before I changed a line.</p>
<p>The matrix is what a rotation of about 0.9 degrees looks like: the cosine is close to 1 and the sine is about 0.0157. It is easy to dismiss that as "almost not rotated". The raster does not care how small the angle is.</p>
<p>The screenshots were part of a fanned deck. I had added the tilt earlier with a comment that kept it under a degree, because past that the captions baked into the images read as crooked. That earlier work measured the caption tops to within 3px. Nobody zoomed into the pixels on a 2x screen, so the blur was designed in and defended by a comment.</p>
<h2>What did the CSS look like before and after?</h2>
<p>Before, every card was a fanned, overlapping layer:</p>
<pre><code class="language-css">.shot {
  flex: 0 0 clamp(11rem, 44vw, 15rem);
  margin: 0 -1.5rem 0 0; /* overlap */
  transition: transform 0.28s cubic-bezier(0.2, 0.7, 0.3, 1);
  will-change: transform;
}
.shot:nth-child(odd)  { transform: rotate(-0.9deg); }
.shot:nth-child(even) { transform: rotate(0.9deg); }

.shot:hover,
.shot:focus-within {
  transform: rotate(0deg) translateY(-0.6rem) scale(1.045);
}
</code></pre>
<p>After, the cards are flat. No tilt, no scale, no <code>will-change</code>, and the hover lift is a plain <code>translate</code> that lands on whole pixels:</p>
<pre><code class="language-css">.shot {
  flex: 0 0 clamp(12rem, 58vw, 16.5rem);
  margin: 0;
}

.shot a.image-zoom {
  transition: translate 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
}

/* Lift, never scale: a whole-pixel translate is the one transform that does
   not resample the screenshot once it lands. */
.shot a.image-zoom:hover,
.shot a.image-zoom:focus-visible {
  translate: 0 -0.375rem;
}
</code></pre>
<p>The rule I wrote down afterwards: never rotate or scale raster content that carries small type, and never put <code>will-change: transform</code> on anything that scales. If a card needs to lift, translate it by a whole number of pixels.</p>
<h2>Was it only the CSS?</h2>
<p>No. There were three more causes, and the CSS was the biggest.</p>
<p>The images were AVIF at quality 75, which left ringing around small UI type. Quality 90 was clean for 6 KB more per card, 25 KB to 31 KB at 640px wide. The cards were also 240px wide, a phone screen shown at a fifth of its size, and clicking one opened the raw file in a new tab. I changed the cards to 264px on desktop and 58vw on a phone, cut the <code>srcset</code> to 384, 640 and 828 at quality 90, and opened a full-height viewer on click.</p>
<p>Because Next.js only serves widths and qualities listed in <code>next.config.js</code>, I added <code>q=90</code> to <code>images.qualities</code>, and a test now reads that config and fails if any rendered image URL asks for a width or quality the optimizer would reject.</p>
<h2>Why were the cover banners blurry as well?</h2>
<p>A second report came the same day: "I meant this", with a screenshot of the cover banner at the top of the Masareef page, not the gallery. I had fixed the wrong images first. The lesson there was to look at the first viewport before deciding which images a report means.</p>
<p>The banner problem was about device pixels. The source art was a 1024x500 store graphic. The hero rendered 1086 CSS pixels wide, which is 2172 device pixels on a 2x screen, so the browser stretched the file by about 2.1 times. An earlier "hi-res" version at 2560px wide was an upscale of the same 1024 pixels, so it was soft too, only larger. The file size had been the clue: 75 KB for 2560 pixels of supposed detail.</p>
<p>The check I keep now is to compare a raster's intrinsic width with the rendered width multiplied by the device pixel ratio, in device pixels and not CSS pixels. I redrew the covers from SVG at 3x, which gave a 3072x1500 source, and fixed the <code>sizes</code> attribute, which had assumed a 768px column when the header was 1086px wide.</p>
<h2>When should text be an image and when should it be live text?</h2>
<p>Redrawing the banner at 3x fixed the 2x screen and exposed the next problem a few hours later. A screenshot of the hero at about 1x showed the wordmark and tagline soft next to the real text above them. At 1x the browser was shrinking 3072 pixels into 768, and shrunk type is filtered just like stretched type.</p>
<p>No file size fixes that, because the words were pixels. I had answered "pixelated" with more pixels twice. The question to ask on the first report is whether the thing is text.</p>
<p>The hero of the five mobile app pages is now live HTML in <code>components/projects/app-banner.tsx</code>: the name, the Arabic name, an accent rule and the tagline are real text over a CSS background of two radial glows and a light SVG grain to avoid banding. The app icon is the only image. The frame is a query container, so the type scales with its own width and one rule covers both the 768px column and a 310px phone column.</p>
<p>The rule that came out of it: text a reader has to read is live text. A raster of type belongs only where the page cannot draw text, such as link-preview cards and store listings. The same lesson says to check sharpness at the pixel density of the screenshot you were sent. My test rig had emulated a 2x laptop, where the 3x cover looked fine, and the report came from a screen at about 1x.</p>
<h2>A checklist for blurry images and text in CSS</h2>
<ol>
<li>Search the stylesheet for <code>will-change: transform</code> and for any <code>rotate()</code> or <code>scale()</code> on elements with images or text. Remove them from anything that carries small type.</li>
<li>Read the computed <code>transform</code> in devtools. A matrix that is not the identity on a card you thought was flat is a lead.</li>
<li>Compare the image's intrinsic width with rendered width times device pixel ratio.</li>
<li>Test at 1x and at 2x. A fix that is sharp at one density can be soft at the other.</li>
<li>If the blurry thing is text, make it text.</li>
</ol>
<p>You can see the finished pages on the <a href="https://www.mohamedgado.com/projects/masareef">Masareef</a>, <a href="https://www.mohamedgado.com/projects/rafiq">Rafiq</a> and <a href="https://www.mohamedgado.com/projects/nafis">Nafis</a> case studies. The site they live on is described in <a href="https://www.mohamedgado.com/projects/portfolio">the portfolio write-up</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Next.js metadata openGraph not inherited: why my subpages lost og:site_name]]></title><description><![CDATA[Next.js merges metadata shallowly, so a page that sets openGraph drops the layout's og:site_name, og:locale and twitter handles. How I fixed it.]]></description><link>https://mohamedgado.hashnode.dev/nextjs-metadata-opengraph-merge</link><guid isPermaLink="true">https://mohamedgado.hashnode.dev/nextjs-metadata-opengraph-merge</guid><category><![CDATA[Next.js]]></category><category><![CDATA[SEO]]></category><category><![CDATA[React]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Mohamed Gado]]></dc:creator><pubDate>Tue, 10 Nov 2026 23:54:44 GMT</pubDate><content:encoded><![CDATA[<p>If a page in the Next.js App Router sets its own <code>openGraph</code>, the openGraph object from your layout is gone for that page. Next.js merges metadata shallowly, so <code>og:site_name</code>, <code>og:locale</code> and any other field you only set in the layout disappear. The fix I use is one helper that builds the full <code>openGraph</code> and <code>twitter</code> objects for every subpage, plus a build check that fails when a tag is missing.</p>
<p>I found this on my own site, <a href="https://www.mohamedgado.com/projects/portfolio">mohamedgado.com</a>. <code>/now</code>, <code>/projects</code> and all 55 case studies were shipping without <code>og:site_name</code>, <code>og:locale</code>, <code>twitter:site</code> and <code>twitter:creator</code>. The root layout set all four. Nothing in the browser looked wrong, and nothing errored.</p>
<h2>Does Next.js merge openGraph metadata from the layout?</h2>
<p>No, not field by field. The Next.js 16 docs for <code>generateMetadata</code> say that metadata objects exported from several segments of one route are shallowly merged, and that duplicate keys are replaced by the segment closest to the page. They also say it directly for nested fields: <code>openGraph</code> defined in an earlier segment is overwritten by the last segment that defines it.</p>
<p>The docs give the example. The layout sets <code>openGraph.title</code> and <code>openGraph.description</code>. The blog page sets only <code>openGraph.title</code>. The output has the page's <code>og:title</code> and no <code>og:description</code>, because the page's whole <code>openGraph</code> object replaced the layout's.</p>
<p>The same page does inherit when it does not set <code>openGraph</code> at all. A subpage with only a <code>title</code> still gets every Open Graph tag from the layout. The trouble starts the moment a page adds <code>openGraph</code> to change one field, such as the title or the image. That is the normal thing to do on a case study or a blog post, so it hits most real sites.</p>
<h2>What did the broken setup look like?</h2>
<p>My root layout carried the full set:</p>
<pre><code class="language-tsx">// app/layout.tsx
export const metadata: Metadata = {
  openGraph: {
    locale: siteConfig.openGraph.locale,
    siteName: siteConfig.name,
    url: siteConfig.links.websiteUrl,
    type: 'website',
    title: `${siteConfig.name} — ${siteConfig.tagline}`,
    description: siteConfig.description,
  },
  twitter: {
    card: 'summary_large_image',
    creator: siteConfig.twitterTag,
    site: siteConfig.twitterTag,
  },
}
</code></pre>
<p>A subpage then set what it needed, which looks harmless. This is a simplified version of what the case-study page did before the helper:</p>
<pre><code class="language-tsx">// app/projects/[slug]/page.tsx (before)
return {
  title: project.title,
  openGraph: {
    title: `${project.title} | Mohamed Gado`,
    description,
    url: `/projects/${slug}`,
    images: [ogImage],
  },
  twitter: {
    card: 'summary_large_image',
    title: `${project.title} | Mohamed Gado`,
    description,
  },
}
</code></pre>
<p>This page's <code>openGraph</code> has no <code>siteName</code> and no <code>locale</code>, so neither tag is rendered. Its <code>twitter</code> has no <code>site</code> or <code>creator</code>, so those are gone too. The layout's values were replaced, not combined.</p>
<h2>How do I check the rendered tags?</h2>
<p>Read the HTML the server sends, not the code. A dev tool that shows the metadata object tells you what you wrote, and the question is what crawlers receive. I curl the page and grep for the tag:</p>
<pre><code class="language-bash">curl -s https://www.mohamedgado.com/projects/masareef | grep -o '&lt;meta[^&gt;]*og:site_name[^&gt;]*&gt;'
curl -s https://www.mohamedgado.com/projects/masareef | grep -oE '&lt;meta[^&gt;]*(og:locale|twitter:site|twitter:creator)[^&gt;]*&gt;'
</code></pre>
<p>An empty result means the tag is missing. I ran this against production before writing any code, because I wanted to know the gap was real on every subpage and not something I had inferred from reading the merge docs. It was.</p>
<h2>What is the fix for Next.js openGraph not inheriting?</h2>
<p>The docs suggest pulling shared fields into a variable and spreading it into each page's <code>openGraph</code>. That works, but it leaves it to every page author to remember the spread. I had 55 case studies built from one template plus a few static pages, so I put the whole thing in a function in <code>lib/social-meta.ts</code>:</p>
<pre><code class="language-ts">import type { Metadata } from 'next'
import { siteConfig } from '@/config/site'

type SocialMetaInput = {
  title: string
  description: string
  /** Root-relative path, resolved against the layout's `metadataBase`. */
  path: string
  images: NonNullable&lt;Metadata['openGraph']&gt;['images']
  /** Case studies are articles; set this and `og:type` becomes `article`. */
  article?: { modifiedTime?: string }
}

export function buildSocialMeta({
  title,
  description,
  path,
  images,
  article,
}: SocialMetaInput): Pick&lt;Metadata, 'openGraph' | 'twitter'&gt; {
  const shared = {
    siteName: siteConfig.name,
    locale: siteConfig.openGraph.locale,
    title,
    description,
    url: path,
    images,
  }
  const twitter: Metadata['twitter'] = {
    card: 'summary_large_image',
    site: siteConfig.twitterTag,
    creator: siteConfig.twitterTag,
    title,
    description,
  }
  if (!article) {
    return { openGraph: { ...shared, type: 'website' }, twitter }
  }
  return {
    openGraph: {
      ...shared,
      type: 'article',
      authors: [siteConfig.links.websiteUrl],
      ...(article.modifiedTime ? { modifiedTime: article.modifiedTime } : {}),
    },
    twitter,
  }
}
</code></pre>
<p>A page spreads the result into its metadata:</p>
<pre><code class="language-tsx">// app/projects/[slug]/page.tsx (after)
return {
  title: project.title,
  alternates: { canonical: `/projects/${slug}` },
  ...buildSocialMeta({
    title: `${project.title} | Mohamed Gado`,
    description,
    path: `/projects/${slug}`,
    images: [ogImage],
  }),
}
</code></pre>
<p>Because <code>buildSocialMeta</code> returns both objects complete, the shallow merge no longer matters: the page's <code>openGraph</code> replaces the layout's, and the replacement already contains everything the layout had. The site name, locale and handles come from <code>siteConfig</code>, the same source the layout reads, so they cannot drift apart.</p>
<p>The helper also gave me a single place to add article fields. Case studies now pass <code>article</code>, which sets <code>og:type</code> to <code>article</code> and adds <code>article:modified_time</code> and <code>article:author</code>.</p>
<p>One detail in the helper: I leave <code>twitter.images</code> unset on purpose. My code comment says Next falls back to the <code>openGraph</code> images, with alt text and dimensions included, so the image is declared once.</p>
<h2>How do I stop it coming back?</h2>
<p>A convention in a README does not survive the next new page. I added a check to the postbuild step. <code>scripts/check-index-parity.ts</code> reads every prerendered page and asserts that it carries <code>og:site_name</code>, <code>og:locale</code> and both Twitter handles. Before the fix it reported 57 failures. After, 0.</p>
<p>The rule written down in my lessons file is short: a page that sets <code>openGraph</code> or <code>twitter</code> goes through <code>buildSocialMeta</code>, and the build fails otherwise. Because the check runs on built HTML, it also catches pages I did not think of, such as a new route someone adds later and forgets to wire up.</p>
<h2>What else should I check on pages that override metadata?</h2>
<p>Other nested fields behave the same way. The docs name <code>robots</code> next to <code>openGraph</code> as a field that is overwritten wholesale. If a layout sets <code>robots: { index: true, follow: true, googleBot: {...} }</code> and one page sets <code>robots: { index: false }</code>, the page does not keep the <code>googleBot</code> block. Anything that is an object in the Metadata type is a candidate: <code>alternates</code>, <code>icons</code>, <code>twitter</code>, <code>appLinks</code> and so on.</p>
<p>For each one, ask whether the layout sets it and whether any page sets it again. If both are true, the page's version has to be complete. A helper function, a spread of a shared object, or a build-time check on the output are all workable. I picked the helper and the check together, since the helper prevents the mistake and the check proves it.</p>
<p>The change was small, and nothing on the site looked different afterwards. What changed was the HTML that link previews and search engines read for 57 pages. If you set <code>openGraph</code> on any page below your root layout, curl one of those pages today and look for <code>og:site_name</code>.</p>
]]></content:encoded></item><item><title><![CDATA[Next.js soft 404: why notFound() returned 200 behind loading.tsx]]></title><description><![CDATA[Unknown slugs on my Next.js App Router site returned HTTP 200 with index and noindex tags. loading.tsx was the cause, dynamicParams = false the fix.]]></description><link>https://mohamedgado.hashnode.dev/nextjs-soft-404-loading-tsx</link><guid isPermaLink="true">https://mohamedgado.hashnode.dev/nextjs-soft-404-loading-tsx</guid><category><![CDATA[Next.js]]></category><category><![CDATA[SEO]]></category><category><![CDATA[Nextjs app router]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Mohamed Gado]]></dc:creator><pubDate>Tue, 10 Nov 2026 23:53:09 GMT</pubDate><content:encoded><![CDATA[<p>On my Next.js 16 site, a URL like <code>/projects/nope</code> returned HTTP 200 and carried two robots tags: <code>index, follow</code> and <code>noindex</code>. The route has a <code>loading.tsx</code>, which starts streaming before <code>notFound()</code> runs, so the status code was already sent. Adding <code>export const dynamicParams = false</code> to the page makes an unknown slug a real 404 before any rendering starts.</p>
<h2>Why does notFound() return 200 in the Next.js App Router?</h2>
<p>I found this during a Search Console pass. Unknown project slugs were not 404s. The Next.js docs describe the mechanism in the <code>loading.js</code> reference: streaming page responses begin with a <code>200</code> status code, and the initial response headers fix the status for the rest of the response. Next.js can still stream <code>notFound()</code> after that, so the browser shows your not-found UI, but the status line stays <code>200</code>.</p>
<p>The docs also say when the stream starts. The response body begins streaming when a Suspense fallback renders, and they give <code>loading.tsx</code> as the example, or when a Server Component suspends under a <code>&lt;Suspense&gt;</code> boundary. Calling <code>notFound()</code> before that point preserves the <code>404</code>. After it, the status cannot change.</p>
<p>A <code>loading.tsx</code> file is a Suspense fallback wrapped around the segment's page. For a dynamic route, that means the fallback can be sent before your page code has a chance to look up the slug and decide it does not exist.</p>
<h2>What does a soft 404 look like for Google?</h2>
<p>A soft 404 is a page that tells the user "not found" but tells the crawler <code>200 OK</code>. Next.js softens the damage: the docs say it injects <code>&lt;meta name="robots" content="noindex" /&gt;</code> into a streamed 404, so search engines are told to keep the URL out of the index even though the status is 200. They also note that some crawlers may label these responses as soft 404s.</p>
<p>On my site the result was messier. The root layout already set <code>index, follow</code>, and the streamed not-found added <code>noindex</code>. The page carried both tags with a 200 status. I did not want to find out which one a crawler would trust, and I did not want Search Console reporting junk URLs as soft 404s.</p>
<h2>How do I reproduce it?</h2>
<p>Here is the shape of the broken segment. The names are simplified from my project route:</p>
<pre><code class="language-tsx">// app/projects/[slug]/page.tsx
import { notFound } from 'next/navigation'

export default async function Page({
  params,
}: {
  params: Promise&lt;{ slug: string }&gt;
}) {
  const { slug } = await params
  const project = findProject(slug)
  if (!project) notFound() // too late: loading.tsx already streamed a 200

  return &lt;ProjectView project={project} /&gt;
}
</code></pre>
<pre><code class="language-tsx">// app/projects/[slug]/loading.tsx
export default function Loading() {
  return &lt;ProjectSkeleton /&gt;
}
</code></pre>
<p>Check the status with curl:</p>
<pre><code class="language-bash">curl -s -o /dev/null -w "%{http_code}\n" https://example.com/projects/nope
</code></pre>
<p>You want <code>404</code>. In my case it printed <code>200</code>. To see the duplicate robots tags, fetch the body:</p>
<pre><code class="language-bash">curl -s https://example.com/projects/nope | grep -o '&lt;meta name="robots"[^&gt;]*&gt;'
</code></pre>
<h2>What fixed it: dynamicParams = false</h2>
<p>Every project on my site is already prerendered through <code>generateStaticParams</code>. The fix was one line in the page file, with a comment I left for my future self:</p>
<pre><code class="language-tsx">// app/projects/[slug]/page.tsx

// Every project is prerendered below, so an unknown slug is a 404 at routing.
// Left on, it rendered on demand and hit notFound() after [slug]/loading.tsx
// had already streamed a 200: a soft 404 carrying both index and noindex.
export const dynamicParams = false

export async function generateStaticParams() {
  const slugs = await listProjectSlugs()
  return slugs.map((slug) =&gt; ({ slug }))
}
</code></pre>
<p>The reference for <code>dynamicParams</code> says that with the default of <code>true</code>, segments not returned by <code>generateStaticParams</code> are generated at request time, and with <code>false</code> they return a 404. An unknown slug never reaches your page component, so <code>loading.tsx</code> never streams for it, and the response is a real 404 from routing.</p>
<p>After the change, an unknown slug is a real 404 from routing. The curl check against <code>/projects/nope</code> on my live site now prints <code>404</code>.</p>
<p>I left <code>loading.tsx</code> in place. At one point I suspected both loading files of bloating the DOM and removed them to test it. The DOM went from 951 to 949 nodes, so they went back. The soft 404 was their only real cost, and <code>dynamicParams</code> fixed that without touching them.</p>
<h2>When is dynamicParams = false the wrong fix?</h2>
<p>It fits when you know every valid slug at build time. If your slugs come from a database that changes after deploy, <code>false</code> would 404 a page that was added after the build, until you rebuild or revalidate.</p>
<p>The docs also list a restriction: <code>dynamicParams</code> is not available when Cache Components is enabled. I did not test that configuration, so check your own setup if you use it.</p>
<p>For a route that has to render on demand, the docs give a different answer. Run the check before the body streams. For a 404, you can check the slug in Proxy, then rewrite to a not-found route or return a 404 response, and the docs say to keep Proxy checks fast and avoid fetching full content there. I use a proxy for a related case on this site: malformed UTF-8 in a URL, such as <code>/projects/%C0%AF</code>, used to be a 500 on decode. <code>proxy.ts</code>, which is the Next 16 name for middleware, now returns a 404 for those. Its matcher only runs on dynamic segments that contain a <code>%</code>, so no other request pays for it.</p>
<p>The other option is to call <code>notFound()</code> before any <code>await</code> on a slow path and before any <code>&lt;Suspense&gt;</code> boundary. The streaming guide recommends exactly that placement to get a real status code.</p>
<h2>How do I check every dynamic route for this?</h2>
<p>The rule I wrote down after this: any dynamic route with a <code>loading.tsx</code> must 404 without rendering. That means <code>dynamicParams = false</code>, or a proxy check.</p>
<p>To audit a site:</p>
<ol>
<li>List the routes with a dynamic segment and a <code>loading.tsx</code> next to them.</li>
<li>Request a nonsense slug on each one from a production build and read the status code with curl.</li>
<li>Anything that prints <code>200</code> with a not-found page is a soft 404.</li>
<li>Fix it with <code>dynamicParams = false</code> if the slug set is known at build time, or with a proxy check if it is not.</li>
</ol>
<p>The site this came from is documented in the <a href="https://www.mohamedgado.com/projects/portfolio">portfolio case study</a>, and the project pages that carry the fix are listed on <a href="https://www.mohamedgado.com/projects">/projects</a>.</p>
]]></content:encoded></item><item><title><![CDATA[Discovered – currently not indexed in Search Console: what it means and what fixed it]]></title><description><![CDATA[50 of my pages sat in Discovered – currently not indexed. Here is what the status means, why Google skipped them, and the fixes that worked.]]></description><link>https://mohamedgado.hashnode.dev/discovered-currently-not-indexed</link><guid isPermaLink="true">https://mohamedgado.hashnode.dev/discovered-currently-not-indexed</guid><category><![CDATA[SEO]]></category><category><![CDATA[Next.js]]></category><category><![CDATA[Google Search Console]]></category><category><![CDATA[webdev]]></category><dc:creator><![CDATA[Mohamed Gado]]></dc:creator><pubDate>Tue, 10 Nov 2026 23:51:02 GMT</pubDate><content:encoded><![CDATA[<p>"Discovered – currently not indexed" means Google knows the URL exists but has not fetched it yet. On my portfolio, 50 project pages landed in that bucket at once, because a batch of thin, near-identical pages made Google spend no crawl effort on any of them. Cutting down to fewer, better pages got them moving, and below are the details and the mistakes I made on the way.</p>
<h2>What does "Discovered – currently not indexed" mean?</h2>
<p>Google found the URL, usually through a sitemap or a link, and put it in a queue. It has not crawled it. That differs from "Crawled – currently not indexed", where Google fetched the page and decided not to keep it.</p>
<p>So the status says nothing about your page's content yet, because Google never looked at it. It says Google chose not to spend a request on it. Two things push that decision: how worth crawling the site as a whole looks, and how many URLs showed up at once.</p>
<h2>Why were 50 of my pages stuck there?</h2>
<p>The 2026-08-09 Search Console export showed the not-indexed count jumping from 6 to 56 on 2026-07-25. Indexed pages had been frozen at 7 since 2026-07-01. The deploy on 2026-07-25 had cross-linked every project page, so all 53 appeared in front of Google at the same time.</p>
<p>About half of those pages were synced from the READMEs of old tutorial repositories. With code blocks stripped, they had between 22 and 190 words of prose, and several were byte-identical to each other. Google read the batch as thin, near-duplicate filler. It spent no crawl budget on any of it, including the projects that had real content.</p>
<p>A site's good pages get judged alongside its bad ones. If you add dozens of weak URLs at once, the strong ones queue behind them.</p>
<h2>How do I fix pages stuck in Discovered – currently not indexed?</h2>
<p>I fixed it in two passes.</p>
<ol>
<li>Pages under a word floor, <code>MIN_INDEXABLE_WORDS</code>, set to 200, render <code>noindex, follow</code> and drop out of the sitemap and every other discovery surface. That cut the set I was asking Google to index from 53 to 32.</li>
<li>I wrote a hand-authored <code>overview</code> for the stub pages. It survives my README sync script, renders above the synced README, and counts toward the threshold. Twenty sidecars got one. The final state was 53 indexable pages and 0 noindex.</li>
</ol>
<p>My first pass was only the first step. I noindexed 21 pages and reported the problem as solved. That relieved the crawl-budget symptom, but it also gave up on a third of the portfolio. Writing real content was the option that raised the indexed count (52 versus 32), and I should have offered it together with the threshold.</p>
<h3>What does the "fewer, better pages" rule look like in code?</h3>
<p>One predicate answers the question, and every surface imports it. The sitemap, the page's robots meta, <code>llms.txt</code> and the IndexNow submission all ask it:</p>
<pre><code class="language-ts">export const MIN_INDEXABLE_WORDS = 200

export function isIndexableSidecar(sidecar: {
  overview?: string
  body: string
  seoIndex?: boolean
}): boolean {
  // An explicit override wins in either direction.
  if (typeof sidecar.seoIndex === 'boolean') return sidecar.seoIndex
  const words = countProseWords(`${sidecar.overview ?? ''}\n\n${sidecar.body}`)
  return words &gt;= MIN_INDEXABLE_WORDS
}
</code></pre>
<p><code>listIndexableProjects()</code> filters the project list through that function. Anything that promotes pages to Google reads that list, so no caller can disagree with the page's own robots tag.</p>
<p>The single predicate earned its place the hard way. For a while the word-count arithmetic was inlined in two files, and IndexNow counted the README body but not the new <code>overview</code>. It silently dropped three pages the sitemap listed. A build step that compares sitemap membership against each page's rendered <code>&lt;meta robots&gt;</code>, <code>llms.txt</code> and the IndexNow set caught it on its first run. Without that check, it would have turned into a Search Console mystery weeks later.</p>
<p>You can see the resulting pages on <a href="https://www.mohamedgado.com/projects">/projects</a>, and the site itself is described in the <a href="https://www.mohamedgado.com/projects/portfolio">portfolio case study</a>.</p>
<h2>Should I click "Request indexing" on every URL in the report?</h2>
<p>No, and this is where I wasted the most time. Request Indexing has a small daily quota, about 10 a day, and the Pages report that lists your stuck URLs lags the live index.</p>
<p>On 2026-11-03 the Pages report showed 49 indexed and 24 not: 12 "Discovered", 6 "Crawled, not indexed", 4 redirects and 2 alternate canonicals. Live URL Inspection told a different story:</p>
<ul>
<li>Of the 12 "Discovered" URLs, 11 were already on Google or had been requested the night before. Only <code>/projects/portfolio</code> had never been crawled.</li>
<li>Of the 6 "Crawled, not indexed" URLs, 5 were OG images and the favicon, which I noindex on purpose. The sixth, <code>/projects/rafiq</code>, was on Google.</li>
<li>The redirects were the http and apex variants of the home page, which is expected.</li>
</ul>
<p>I had spent a request on <code>/projects/angular-todo</code> straight from the report's list, and it was already on Google. The Pages report can be days to weeks behind.</p>
<p>The same thing happened on a second site of mine. Six translated pages were listed as "Discovered, not indexed". Live inspection showed five of the six were already on Google, so one request was enough.</p>
<p>My rule now: inspect each URL live first, and request indexing only for the ones Google reports as unknown.</p>
<h3>Does the Request Indexing quota reset at midnight?</h3>
<p>I assumed it did and told myself the queue would be free the next day. One request went through at the start of the day, and then "Quota exceeded" came back. The quota frees as each request from the previous day ages out, so it works as a rolling window. Spread the queue across the day.</p>
<p>The quota is also per Google account, shared by every property you own. My other site hit "Quota exceeded" after 2 requests because the portfolio had used the rest.</p>
<p>On 2026-10-17 I requested indexing for 11 pages before the 12th returned "Quota exceeded".</p>
<h3>What is the difference between "Something went wrong" and "Quota exceeded"?</h3>
<p>"Quota exceeded" is the only quota message. On the seventh request of one session I got "Oops! Something went wrong ... Please try again later." and read it as the quota again. A single retry after dismissing it went through. Retry once before assuming you are out of quota.</p>
<h3>What does "No referring sitemaps detected" mean?</h3>
<p>For a page Google has never seen, the inspection can say "No referring sitemaps detected". I took that for a missing sitemap entry. The live sitemap had the URL, and Google had not re-read the sitemap since I added the page. Check your live sitemap before changing code.</p>
<h2>What changed in the numbers?</h2>
<p>Search Console between 2026-10-09 and 2026-10-17, after the fixes above:</p>
<table>
<thead>
<tr>
<th></th>
<th>10-09</th>
<th>10-17</th>
</tr>
</thead>
<tbody><tr>
<td>Indexed</td>
<td>18</td>
<td>28</td>
</tr>
<tr>
<td>Discovered, never crawled</td>
<td>42</td>
<td>32</td>
</tr>
<tr>
<td>Unknown to Google</td>
<td>n/a</td>
<td>1 (<code>postkit</code>, shipped 10-15)</td>
</tr>
</tbody></table>
<p>By 2026-11-03 the Pages report showed 49 indexed. The redirects, a <code>?ref</code> canonical and the noindexed images in the remaining rows were working as designed.</p>
<h2>What should I check first on my own site?</h2>
<ol>
<li>Open each URL in URL Inspection and read the live status. The Pages report is a lagging summary.</li>
<li>Count the words of real prose on the pages in the bucket. If a large share are stubs or copies of each other, that is your answer.</li>
<li>Decide per page: write real content for it, or mark it <code>noindex, follow</code> and take it out of your sitemap and internal link lists.</li>
<li>Keep one function that decides what is indexable, and have the sitemap, the robots tag and any submission scripts all call it.</li>
<li>Request indexing only for URLs that inspection reports as unknown to Google, and spread the requests across days.</li>
</ol>
<p>I also checked the site before blaming Google. A crawl of all 59 sitemap URLs on production with a Googlebot user agent returned a 200 on every page, each with a self-canonical, <code>index, follow</code> and one <code>&lt;h1&gt;</code>, and no duplicate titles or descriptions. When the site is clean and the bucket is still full, the cause is usually page quality or volume.</p>
]]></content:encoded></item></channel></rss>