<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mohamed Gado</title>
    <description>The latest articles on DEV Community by Mohamed Gado (@mohamedgado).</description>
    <link>https://dev.to/mohamedgado</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4238010%2Fd10686cd-6068-4019-9490-e8fd07f17396.jpg</url>
      <title>DEV Community: Mohamed Gado</title>
      <link>https://dev.to/mohamedgado</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mohamedgado"/>
    <language>en</language>
    <item>
      <title>React error #418: fixing a matchMedia hydration mismatch with useSyncExternalStore</title>
      <dc:creator>Mohamed Gado</dc:creator>
      <pubDate>Wed, 11 Nov 2026 00:03:29 +0000</pubDate>
      <link>https://dev.to/mohamedgado/react-error-418-fixing-a-matchmedia-hydration-mismatch-with-usesyncexternalstore-3np7</link>
      <guid>https://dev.to/mohamedgado/react-error-418-fixing-a-matchmedia-hydration-mismatch-with-usesyncexternalstore-3np7</guid>
      <description>&lt;p&gt;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 &lt;code&gt;useMediaQuery&lt;/code&gt; hook that read &lt;code&gt;window.matchMedia(query).matches&lt;/code&gt; inside a &lt;code&gt;useState&lt;/code&gt; initializer. The server returned &lt;code&gt;false&lt;/code&gt;, a desktop browser returned &lt;code&gt;true&lt;/code&gt;, and React threw away the server HTML. Rewriting the hook on &lt;code&gt;useSyncExternalStore&lt;/code&gt; with a &lt;code&gt;getServerSnapshot&lt;/code&gt; of &lt;code&gt;false&lt;/code&gt; made the first client render match the server by construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does minified React error #418 mean?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;In production the message is minified, so all you get is &lt;code&gt;#418&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does reading matchMedia in useState cause a hydration mismatch?
&lt;/h2&gt;

&lt;p&gt;The server has no &lt;code&gt;window&lt;/code&gt;. A hook that wants to run on both sides has to pick a value for the server, and the natural choice is &lt;code&gt;false&lt;/code&gt;. The mistake is reading the browser value during the first client render.&lt;/p&gt;

&lt;p&gt;This is the shape of my broken hook. It is simplified, not the exact file I had:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'react'&lt;/span&gt;

&lt;span class="c1"&gt;// Broken: the first client render reads the browser, the server cannot.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useMediaQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;setMatches&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'undefined'&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;mql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;onChange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setMatches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'change'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'change'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the server the initializer returns &lt;code&gt;false&lt;/code&gt;. In a desktop browser the same initializer runs during the first client render and returns &lt;code&gt;true&lt;/code&gt;. Hydration compares the two trees at that moment, so the value is already different before any effect runs.&lt;/p&gt;

&lt;p&gt;The caller made it visible. My spotlight component draws a cursor-following overlay only when the device has a fine pointer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;hasFinePointer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMediaQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'(pointer: fine)'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hasFinePointer&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;div&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;overlay&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="n"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"pointer-events-none fixed inset-0 z-30 ..."&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server rendered no overlay. The desktop client rendered one extra child, so every sibling after it shifted position. That is enough for #418.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I find which component mismatches?
&lt;/h2&gt;

&lt;p&gt;Production will not tell you. Run &lt;code&gt;pnpm dev&lt;/code&gt; (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 &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;-&lt;/code&gt; lines that mark the exact mismatched node. The &lt;code&gt;+&lt;/code&gt; side is what the client rendered and the &lt;code&gt;-&lt;/code&gt; side is what the server sent. For my bug that diff pointed at the spotlight overlay &lt;code&gt;div&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two things to check once you have the component:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does it read &lt;code&gt;window&lt;/code&gt;, &lt;code&gt;document&lt;/code&gt;, &lt;code&gt;localStorage&lt;/code&gt;, &lt;code&gt;matchMedia&lt;/code&gt; or &lt;code&gt;Date.now()&lt;/code&gt; while rendering, or in a &lt;code&gt;useState&lt;/code&gt; initializer?&lt;/li&gt;
&lt;li&gt;Does it branch the JSX on that value?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;A second clue was sitting in my own code. A &lt;code&gt;mounted&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I fix it with useSyncExternalStore?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;useSyncExternalStore&lt;/code&gt; takes three arguments: &lt;code&gt;subscribe&lt;/code&gt;, &lt;code&gt;getSnapshot&lt;/code&gt; and &lt;code&gt;getServerSnapshot&lt;/code&gt;. From the React docs, &lt;code&gt;getServerSnapshot&lt;/code&gt; is used during server rendering and during hydration on the client. That is the property that matters here. React does not call your client &lt;code&gt;getSnapshot&lt;/code&gt; for the hydrating render, so the first client render sees the same &lt;code&gt;false&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;This is the hook as it is now in my repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;useSyncExternalStore&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'react'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useMediaQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// One MediaQueryList per query, reused by both subscribe and getSnapshot.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;mql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'undefined'&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;subscribe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;onStoreChange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;mql&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'change'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;onStoreChange&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mql&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'change'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;onStoreChange&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mql&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;useSyncExternalStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;mql&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;matches&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in it are worth copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;MediaQueryList&lt;/code&gt; is created once per query in &lt;code&gt;useMemo&lt;/code&gt;. &lt;code&gt;getSnapshot&lt;/code&gt; runs on every render, so calling &lt;code&gt;matchMedia&lt;/code&gt; inside it would repeat work for no reason.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;subscribe&lt;/code&gt; is wrapped in &lt;code&gt;useCallback&lt;/code&gt; keyed on the list. A new function identity each render would make React unsubscribe and resubscribe every time.&lt;/li&gt;
&lt;li&gt;The server snapshot is a constant &lt;code&gt;false&lt;/code&gt;. That is the contract for callers: the first paint always has &lt;code&gt;false&lt;/code&gt;, and the real value arrives in the commit right after.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Do I still need a mounted guard?
&lt;/h2&gt;

&lt;p&gt;No. Once the hook returns &lt;code&gt;false&lt;/code&gt; on the first client render, a &lt;code&gt;mounted&lt;/code&gt; state flag adds nothing. I deleted the guard in the palette trigger. That component now hides itself on desktop with the Tailwind class &lt;code&gt;sm:hidden&lt;/code&gt;, which is CSS and needs no JavaScript value at all.&lt;/p&gt;

&lt;p&gt;This points at the cheaper option for many cases. If a media query only changes layout or visibility, use CSS. Keep &lt;code&gt;useMediaQuery&lt;/code&gt; for the cases where JavaScript has to act on the result, such as attaching a mouse handler, as my spotlight does.&lt;/p&gt;

&lt;p&gt;The trade-off is a one-frame flash. With a &lt;code&gt;false&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I confirm the fix worked?
&lt;/h2&gt;

&lt;p&gt;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, &lt;code&gt;pnpm dev&lt;/code&gt; is where to look, since it prints the full hydration warning with its component stack.&lt;/p&gt;

&lt;p&gt;One trap while checking: counting JSON-LD tags with a bare grep for &lt;code&gt;application/ld+json&lt;/code&gt; double-counts, because the RSC payload embeds the component tree as an escaped string. Count real tags with &lt;code&gt;grep -o '&amp;lt;script type="application/ld+json"'&lt;/code&gt; instead, or &lt;code&gt;document.querySelectorAll&lt;/code&gt; in the browser.&lt;/p&gt;

&lt;p&gt;The rule I took from it: never read &lt;code&gt;window&lt;/code&gt;, &lt;code&gt;matchMedia&lt;/code&gt; or &lt;code&gt;localStorage&lt;/code&gt; in a &lt;code&gt;useState&lt;/code&gt; initializer in a component that renders on the server. Use &lt;code&gt;useSyncExternalStore&lt;/code&gt; with a server snapshot, or use CSS.&lt;/p&gt;

&lt;p&gt;The site this came from is described in the &lt;a href="https://www.mohamedgado.com/projects/portfolio" rel="noopener noreferrer"&gt;portfolio case study&lt;/a&gt;. Another rendering bug from the same site, a 200 status on unknown slugs, is covered in &lt;a href="https://www.mohamedgado.com/writing/nextjs-soft-404-loading-tsx" rel="noopener noreferrer"&gt;Next.js soft 404: why notFound() returned 200 behind loading.tsx&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>react</category>
      <category>nextjs</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Next.js soft 404: why notFound() returned 200 behind loading.tsx</title>
      <dc:creator>Mohamed Gado</dc:creator>
      <pubDate>Wed, 11 Nov 2026 00:03:03 +0000</pubDate>
      <link>https://dev.to/mohamedgado/nextjs-soft-404-why-notfound-returned-200-behind-loadingtsx-1ffb</link>
      <guid>https://dev.to/mohamedgado/nextjs-soft-404-why-notfound-returned-200-behind-loadingtsx-1ffb</guid>
      <description>&lt;p&gt;On my Next.js 16 site, a URL like &lt;code&gt;/projects/nope&lt;/code&gt; returned HTTP 200 and carried two robots tags: &lt;code&gt;index, follow&lt;/code&gt; and &lt;code&gt;noindex&lt;/code&gt;. The route has a &lt;code&gt;loading.tsx&lt;/code&gt;, which starts streaming before &lt;code&gt;notFound()&lt;/code&gt; runs, so the status code was already sent. Adding &lt;code&gt;export const dynamicParams = false&lt;/code&gt; to the page makes an unknown slug a real 404 before any rendering starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does notFound() return 200 in the Next.js App Router?
&lt;/h2&gt;

&lt;p&gt;I found this during a Search Console pass. Unknown project slugs were not 404s. The Next.js docs describe the mechanism in the &lt;code&gt;loading.js&lt;/code&gt; reference: streaming page responses begin with a &lt;code&gt;200&lt;/code&gt; status code, and the initial response headers fix the status for the rest of the response. Next.js can still stream &lt;code&gt;notFound()&lt;/code&gt; after that, so the browser shows your not-found UI, but the status line stays &lt;code&gt;200&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The docs also say when the stream starts. The response body begins streaming when a Suspense fallback renders, and they give &lt;code&gt;loading.tsx&lt;/code&gt; as the example, or when a Server Component suspends under a &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt; boundary. Calling &lt;code&gt;notFound()&lt;/code&gt; before that point preserves the &lt;code&gt;404&lt;/code&gt;. After it, the status cannot change.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;loading.tsx&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a soft 404 look like for Google?
&lt;/h2&gt;

&lt;p&gt;A soft 404 is a page that tells the user "not found" but tells the crawler &lt;code&gt;200 OK&lt;/code&gt;. Next.js softens the damage: the docs say it injects &lt;code&gt;&amp;lt;meta name="robots" content="noindex" /&amp;gt;&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;On my site the result was messier. The root layout already set &lt;code&gt;index, follow&lt;/code&gt;, and the streamed not-found added &lt;code&gt;noindex&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I reproduce it?
&lt;/h2&gt;

&lt;p&gt;Here is the shape of the broken segment. The names are simplified from my project route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/projects/[slug]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;notFound&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'next/navigation'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nc"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findProject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// too late: loading.tsx already streamed a 200&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProjectView&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/projects/[slug]/loading.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nc"&gt;Loading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProjectSkeleton&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the status with curl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s -o /dev/null -w &lt;span class="s2"&gt;"%{http_code}\n"&lt;/span&gt; https://example.com/projects/nope
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want &lt;code&gt;404&lt;/code&gt;. In my case it printed &lt;code&gt;200&lt;/code&gt;. To see the duplicate robots tags, fetch the body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s https://example.com/projects/nope &lt;span class="o"&gt;|&lt;/span&gt; grep -o &lt;span class="s1"&gt;'&amp;lt;meta name="robots"[^&amp;gt;]*&amp;gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What fixed it: dynamicParams = false
&lt;/h2&gt;

&lt;p&gt;Every project on my site is already prerendered through &lt;code&gt;generateStaticParams&lt;/code&gt;. The fix was one line in the page file, with a comment I left for my future self:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/projects/[slug]/page.tsx&lt;/span&gt;

&lt;span class="c1"&gt;// Every project is prerendered below, so an unknown slug is a 404 at routing.&lt;/span&gt;
&lt;span class="c1"&gt;// Left on, it rendered on demand and hit notFound() after [slug]/loading.tsx&lt;/span&gt;
&lt;span class="c1"&gt;// had already streamed a 200: a soft 404 carrying both index and noindex.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;dynamicParams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateStaticParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;slugs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;listProjectSlugs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;slugs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reference for &lt;code&gt;dynamicParams&lt;/code&gt; says that with the default of &lt;code&gt;true&lt;/code&gt;, segments not returned by &lt;code&gt;generateStaticParams&lt;/code&gt; are generated at request time, and with &lt;code&gt;false&lt;/code&gt; they return a 404. An unknown slug never reaches your page component, so &lt;code&gt;loading.tsx&lt;/code&gt; never streams for it, and the response is a real 404 from routing.&lt;/p&gt;

&lt;p&gt;After the change, an unknown slug is a real 404 from routing. The curl check against &lt;code&gt;/projects/nope&lt;/code&gt; on my live site now prints &lt;code&gt;404&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I left &lt;code&gt;loading.tsx&lt;/code&gt; 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 &lt;code&gt;dynamicParams&lt;/code&gt; fixed that without touching them.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is dynamicParams = false the wrong fix?
&lt;/h2&gt;

&lt;p&gt;It fits when you know every valid slug at build time. If your slugs come from a database that changes after deploy, &lt;code&gt;false&lt;/code&gt; would 404 a page that was added after the build, until you rebuild or revalidate.&lt;/p&gt;

&lt;p&gt;The docs also list a restriction: &lt;code&gt;dynamicParams&lt;/code&gt; is not available when Cache Components is enabled. I did not test that configuration, so check your own setup if you use it.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;/projects/%C0%AF&lt;/code&gt;, used to be a 500 on decode. &lt;code&gt;proxy.ts&lt;/code&gt;, which is the Next 16 name for middleware, now returns a 404 for those. Its matcher only runs on dynamic segments that contain a &lt;code&gt;%&lt;/code&gt;, so no other request pays for it.&lt;/p&gt;

&lt;p&gt;The other option is to call &lt;code&gt;notFound()&lt;/code&gt; before any &lt;code&gt;await&lt;/code&gt; on a slow path and before any &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt; boundary. The streaming guide recommends exactly that placement to get a real status code.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I check every dynamic route for this?
&lt;/h2&gt;

&lt;p&gt;The rule I wrote down after this: any dynamic route with a &lt;code&gt;loading.tsx&lt;/code&gt; must 404 without rendering. That means &lt;code&gt;dynamicParams = false&lt;/code&gt;, or a proxy check.&lt;/p&gt;

&lt;p&gt;To audit a site:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;List the routes with a dynamic segment and a &lt;code&gt;loading.tsx&lt;/code&gt; next to them.&lt;/li&gt;
&lt;li&gt;Request a nonsense slug on each one from a production build and read the status code with curl.&lt;/li&gt;
&lt;li&gt;Anything that prints &lt;code&gt;200&lt;/code&gt; with a not-found page is a soft 404.&lt;/li&gt;
&lt;li&gt;Fix it with &lt;code&gt;dynamicParams = false&lt;/code&gt; if the slug set is known at build time, or with a proxy check if it is not.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The site this came from is documented in the &lt;a href="https://www.mohamedgado.com/projects/portfolio" rel="noopener noreferrer"&gt;portfolio case study&lt;/a&gt;, and the project pages that carry the fix are listed on &lt;a href="https://www.mohamedgado.com/projects" rel="noopener noreferrer"&gt;/projects&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>seo</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Next.js metadata openGraph not inherited: why my subpages lost og:site_name</title>
      <dc:creator>Mohamed Gado</dc:creator>
      <pubDate>Wed, 11 Nov 2026 00:02:39 +0000</pubDate>
      <link>https://dev.to/mohamedgado/nextjs-metadata-opengraph-not-inherited-why-my-subpages-lost-ogsitename-5big</link>
      <guid>https://dev.to/mohamedgado/nextjs-metadata-opengraph-not-inherited-why-my-subpages-lost-ogsitename-5big</guid>
      <description>&lt;p&gt;If a page in the Next.js App Router sets its own &lt;code&gt;openGraph&lt;/code&gt;, the openGraph object from your layout is gone for that page. Next.js merges metadata shallowly, so &lt;code&gt;og:site_name&lt;/code&gt;, &lt;code&gt;og:locale&lt;/code&gt; and any other field you only set in the layout disappear. The fix I use is one helper that builds the full &lt;code&gt;openGraph&lt;/code&gt; and &lt;code&gt;twitter&lt;/code&gt; objects for every subpage, plus a build check that fails when a tag is missing.&lt;/p&gt;

&lt;p&gt;I found this on my own site, &lt;a href="https://www.mohamedgado.com/projects/portfolio" rel="noopener noreferrer"&gt;mohamedgado.com&lt;/a&gt;. &lt;code&gt;/now&lt;/code&gt;, &lt;code&gt;/projects&lt;/code&gt; and all 55 case studies were shipping without &lt;code&gt;og:site_name&lt;/code&gt;, &lt;code&gt;og:locale&lt;/code&gt;, &lt;code&gt;twitter:site&lt;/code&gt; and &lt;code&gt;twitter:creator&lt;/code&gt;. The root layout set all four. Nothing in the browser looked wrong, and nothing errored.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does Next.js merge openGraph metadata from the layout?
&lt;/h2&gt;

&lt;p&gt;No, not field by field. The Next.js 16 docs for &lt;code&gt;generateMetadata&lt;/code&gt; 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: &lt;code&gt;openGraph&lt;/code&gt; defined in an earlier segment is overwritten by the last segment that defines it.&lt;/p&gt;

&lt;p&gt;The docs give the example. The layout sets &lt;code&gt;openGraph.title&lt;/code&gt; and &lt;code&gt;openGraph.description&lt;/code&gt;. The blog page sets only &lt;code&gt;openGraph.title&lt;/code&gt;. The output has the page's &lt;code&gt;og:title&lt;/code&gt; and no &lt;code&gt;og:description&lt;/code&gt;, because the page's whole &lt;code&gt;openGraph&lt;/code&gt; object replaced the layout's.&lt;/p&gt;

&lt;p&gt;The same page does inherit when it does not set &lt;code&gt;openGraph&lt;/code&gt; at all. A subpage with only a &lt;code&gt;title&lt;/code&gt; still gets every Open Graph tag from the layout. The trouble starts the moment a page adds &lt;code&gt;openGraph&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did the broken setup look like?
&lt;/h2&gt;

&lt;p&gt;My root layout carried the full set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;siteConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;siteName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;siteConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;siteConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;links&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;websiteUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'website'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`${siteConfig.name} — ${siteConfig.tagline}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;siteConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;twitter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'summary_large_image'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;creator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;siteConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;twitterTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;siteConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;twitterTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/projects/[slug]/page.tsx (before)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`${project.title} | Mohamed Gado`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`/projects/${slug}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ogImage&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;twitter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'summary_large_image'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`${project.title} | Mohamed Gado`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This page's &lt;code&gt;openGraph&lt;/code&gt; has no &lt;code&gt;siteName&lt;/code&gt; and no &lt;code&gt;locale&lt;/code&gt;, so neither tag is rendered. Its &lt;code&gt;twitter&lt;/code&gt; has no &lt;code&gt;site&lt;/code&gt; or &lt;code&gt;creator&lt;/code&gt;, so those are gone too. The layout's values were replaced, not combined.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I check the rendered tags?
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -s https://www.mohamedgado.com/projects/masareef &lt;span class="o"&gt;|&lt;/span&gt; grep -o &lt;span class="s1"&gt;'&amp;lt;meta[^&amp;gt;]*og:site_name[^&amp;gt;]*&amp;gt;'&lt;/span&gt;
curl -s https://www.mohamedgado.com/projects/masareef &lt;span class="o"&gt;|&lt;/span&gt; grep -oE &lt;span class="s1"&gt;'&amp;lt;meta[^&amp;gt;]*(og:locale|twitter:site|twitter:creator)[^&amp;gt;]*&amp;gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the fix for Next.js openGraph not inheriting?
&lt;/h2&gt;

&lt;p&gt;The docs suggest pulling shared fields into a variable and spreading it into each page's &lt;code&gt;openGraph&lt;/code&gt;. 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 &lt;code&gt;lib/social-meta.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Metadata&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'next'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;siteConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'@/config/site'&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nc"&gt;SocialMetaInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
  &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
  &lt;span class="cm"&gt;/** Root-relative path, resolved against the layout's `metadataBase`. */&lt;/span&gt;
  &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
  &lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;NonNullable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'openGraph'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'images'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="cm"&gt;/** Case studies are articles; set this and `og:type` becomes `article`. */&lt;/span&gt;
  &lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;modifiedTime&lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildSocialMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SocialMetaInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Pick&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'openGraph'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="s1"&gt;'twitter'&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;shared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;siteName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;siteConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;siteConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;twitter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Metadata&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'twitter'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'summary_large_image'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;site&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;siteConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;twitterTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;creator&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;siteConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;twitterTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'website'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;twitter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'article'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;authors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;siteConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;links&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;websiteUrl&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modifiedTime&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;modifiedTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modifiedTime&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;twitter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A page spreads the result into its metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/projects/[slug]/page.tsx (after)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`/projects/${slug}`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;buildSocialMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`${project.title} | Mohamed Gado`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sb"&gt;`/projects/${slug}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ogImage&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;buildSocialMeta&lt;/code&gt; returns both objects complete, the shallow merge no longer matters: the page's &lt;code&gt;openGraph&lt;/code&gt; replaces the layout's, and the replacement already contains everything the layout had. The site name, locale and handles come from &lt;code&gt;siteConfig&lt;/code&gt;, the same source the layout reads, so they cannot drift apart.&lt;/p&gt;

&lt;p&gt;The helper also gave me a single place to add article fields. Case studies now pass &lt;code&gt;article&lt;/code&gt;, which sets &lt;code&gt;og:type&lt;/code&gt; to &lt;code&gt;article&lt;/code&gt; and adds &lt;code&gt;article:modified_time&lt;/code&gt; and &lt;code&gt;article:author&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One detail in the helper: I leave &lt;code&gt;twitter.images&lt;/code&gt; unset on purpose. My code comment says Next falls back to the &lt;code&gt;openGraph&lt;/code&gt; images, with alt text and dimensions included, so the image is declared once.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I stop it coming back?
&lt;/h2&gt;

&lt;p&gt;A convention in a README does not survive the next new page. I added a check to the postbuild step. &lt;code&gt;scripts/check-index-parity.ts&lt;/code&gt; reads every prerendered page and asserts that it carries &lt;code&gt;og:site_name&lt;/code&gt;, &lt;code&gt;og:locale&lt;/code&gt; and both Twitter handles. Before the fix it reported 57 failures. After, 0.&lt;/p&gt;

&lt;p&gt;The rule written down in my lessons file is short: a page that sets &lt;code&gt;openGraph&lt;/code&gt; or &lt;code&gt;twitter&lt;/code&gt; goes through &lt;code&gt;buildSocialMeta&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What else should I check on pages that override metadata?
&lt;/h2&gt;

&lt;p&gt;Other nested fields behave the same way. The docs name &lt;code&gt;robots&lt;/code&gt; next to &lt;code&gt;openGraph&lt;/code&gt; as a field that is overwritten wholesale. If a layout sets &lt;code&gt;robots: { index: true, follow: true, googleBot: {...} }&lt;/code&gt; and one page sets &lt;code&gt;robots: { index: false }&lt;/code&gt;, the page does not keep the &lt;code&gt;googleBot&lt;/code&gt; block. Anything that is an object in the Metadata type is a candidate: &lt;code&gt;alternates&lt;/code&gt;, &lt;code&gt;icons&lt;/code&gt;, &lt;code&gt;twitter&lt;/code&gt;, &lt;code&gt;appLinks&lt;/code&gt; and so on.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;openGraph&lt;/code&gt; on any page below your root layout, curl one of those pages today and look for &lt;code&gt;og:site_name&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>seo</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Discovered – currently not indexed in Search Console: what it means and what fixed it</title>
      <dc:creator>Mohamed Gado</dc:creator>
      <pubDate>Wed, 11 Nov 2026 00:02:03 +0000</pubDate>
      <link>https://dev.to/mohamedgado/discovered-currently-not-indexed-in-search-console-what-it-means-and-what-fixed-it-50n1</link>
      <guid>https://dev.to/mohamedgado/discovered-currently-not-indexed-in-search-console-what-it-means-and-what-fixed-it-50n1</guid>
      <description>&lt;p&gt;"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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does "Discovered – currently not indexed" mean?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why were 50 of my pages stuck there?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I fix pages stuck in Discovered – currently not indexed?
&lt;/h2&gt;

&lt;p&gt;I fixed it in two passes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pages under a word floor, &lt;code&gt;MIN_INDEXABLE_WORDS&lt;/code&gt;, set to 200, render &lt;code&gt;noindex, follow&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;I wrote a hand-authored &lt;code&gt;overview&lt;/code&gt; 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.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does the "fewer, better pages" rule look like in code?
&lt;/h3&gt;

&lt;p&gt;One predicate answers the question, and every surface imports it. The sitemap, the page's robots meta, &lt;code&gt;llms.txt&lt;/code&gt; and the IndexNow submission all ask it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="no"&gt;MIN_INDEXABLE_WORDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isIndexableSidecar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sidecar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;overview&lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
  &lt;span class="n"&gt;seoIndex&lt;/span&gt;&lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// An explicit override wins in either direction.&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="n"&gt;sidecar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seoIndex&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'boolean'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sidecar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seoIndex&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;countProseWords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sb"&gt;`${sidecar.overview ?? ''}\n\n${sidecar.body}`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;words&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="no"&gt;MIN_INDEXABLE_WORDS&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;listIndexableProjects()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;overview&lt;/code&gt;. It silently dropped three pages the sitemap listed. A build step that compares sitemap membership against each page's rendered &lt;code&gt;&amp;lt;meta robots&amp;gt;&lt;/code&gt;, &lt;code&gt;llms.txt&lt;/code&gt; and the IndexNow set caught it on its first run. Without that check, it would have turned into a Search Console mystery weeks later.&lt;/p&gt;

&lt;p&gt;You can see the resulting pages on &lt;a href="https://www.mohamedgado.com/projects" rel="noopener noreferrer"&gt;/projects&lt;/a&gt;, and the site itself is described in the &lt;a href="https://www.mohamedgado.com/projects/portfolio" rel="noopener noreferrer"&gt;portfolio case study&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I click "Request indexing" on every URL in the report?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Of the 12 "Discovered" URLs, 11 were already on Google or had been requested the night before. Only &lt;code&gt;/projects/portfolio&lt;/code&gt; had never been crawled.&lt;/li&gt;
&lt;li&gt;Of the 6 "Crawled, not indexed" URLs, 5 were OG images and the favicon, which I noindex on purpose. The sixth, &lt;code&gt;/projects/rafiq&lt;/code&gt;, was on Google.&lt;/li&gt;
&lt;li&gt;The redirects were the http and apex variants of the home page, which is expected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I had spent a request on &lt;code&gt;/projects/angular-todo&lt;/code&gt; straight from the report's list, and it was already on Google. The Pages report can be days to weeks behind.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;My rule now: inspect each URL live first, and request indexing only for the ones Google reports as unknown.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the Request Indexing quota reset at midnight?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;On 2026-10-17 I requested indexing for 11 pages before the 12th returned "Quota exceeded".&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between "Something went wrong" and "Quota exceeded"?
&lt;/h3&gt;

&lt;p&gt;"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.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does "No referring sitemaps detected" mean?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in the numbers?
&lt;/h2&gt;

&lt;p&gt;Search Console between 2026-10-09 and 2026-10-17, after the fixes above:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;10-09&lt;/th&gt;
&lt;th&gt;10-17&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Indexed&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovered, never crawled&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unknown to Google&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;1 (&lt;code&gt;postkit&lt;/code&gt;, shipped 10-15)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By 2026-11-03 the Pages report showed 49 indexed. The redirects, a &lt;code&gt;?ref&lt;/code&gt; canonical and the noindexed images in the remaining rows were working as designed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should I check first on my own site?
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open each URL in URL Inspection and read the live status. The Pages report is a lagging summary.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Decide per page: write real content for it, or mark it &lt;code&gt;noindex, follow&lt;/code&gt; and take it out of your sitemap and internal link lists.&lt;/li&gt;
&lt;li&gt;Keep one function that decides what is indexable, and have the sitemap, the robots tag and any submission scripts all call it.&lt;/li&gt;
&lt;li&gt;Request indexing only for URLs that inspection reports as unknown to Google, and spread the requests across days.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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, &lt;code&gt;index, follow&lt;/code&gt; and one &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt;, 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.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>google</category>
      <category>nextjs</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why a CSS rotate with will-change: transform makes images and text blurry</title>
      <dc:creator>Mohamed Gado</dc:creator>
      <pubDate>Wed, 11 Nov 2026 00:01:25 +0000</pubDate>
      <link>https://dev.to/mohamedgado/why-a-css-rotate-with-will-change-transform-makes-images-and-text-blurry-pb0</link>
      <guid>https://dev.to/mohamedgado/why-a-css-rotate-with-will-change-transform-makes-images-and-text-blurry-pb0</guid>
      <description>&lt;p&gt;If an element is rotated or scaled and has &lt;code&gt;will-change: transform&lt;/code&gt;, 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 &lt;code&gt;will-change: transform&lt;/code&gt; and a hover scale. Removing the tilt, the scale and the &lt;code&gt;will-change&lt;/code&gt; made them sharp.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does will-change: transform make things blurry?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;will-change: transform&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How did I confirm the cause?
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;matrix(0.999877, -0.0157…)&lt;/code&gt;, &lt;code&gt;will-change: transform&lt;/code&gt;, and an image request for &lt;code&gt;w=640&amp;amp;q=75&lt;/code&gt;. That was the diagnosis before I changed a line.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did the CSS look like before and after?
&lt;/h2&gt;

&lt;p&gt;Before, every card was a fanned, overlapping layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.shot&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;11rem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;44vw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15rem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;-1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;5rem&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="cm"&gt;/* overlap */&lt;/span&gt;
  &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;transform&lt;/span&gt; &lt;span class="mf"&gt;0.28s&lt;/span&gt; &lt;span class="nf"&gt;cubic-bezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;will-change&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.shot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nf"&gt;nth-child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;odd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;-0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;9deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.shot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nf"&gt;nth-child&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;even&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.9deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.shot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;hover&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nc"&gt;.shot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;focus-within&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;-0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;6rem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.045&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After, the cards are flat. No tilt, no scale, no &lt;code&gt;will-change&lt;/code&gt;, and the hover lift is a plain &lt;code&gt;translate&lt;/code&gt; that lands on whole pixels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.shot&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12rem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;58vw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;16.5rem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.shot&lt;/span&gt; &lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="nc"&gt;.image-zoom&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;translate&lt;/span&gt; &lt;span class="mf"&gt;0.32s&lt;/span&gt; &lt;span class="nf"&gt;cubic-bezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/* Lift, never scale: a whole-pixel translate is the one transform that does
   not resample the screenshot once it lands. */&lt;/span&gt;
&lt;span class="nc"&gt;.shot&lt;/span&gt; &lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="nc"&gt;.image-zoom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;hover&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="nc"&gt;.shot&lt;/span&gt; &lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="nc"&gt;.image-zoom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nt"&gt;focus-visible&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="n"&gt;-0&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;375rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule I wrote down afterwards: never rotate or scale raster content that carries small type, and never put &lt;code&gt;will-change: transform&lt;/code&gt; on anything that scales. If a card needs to lift, translate it by a whole number of pixels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it only the CSS?
&lt;/h2&gt;

&lt;p&gt;No. There were three more causes, and the CSS was the biggest.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;srcset&lt;/code&gt; to 384, 640 and 828 at quality 90, and opened a full-height viewer on click.&lt;/p&gt;

&lt;p&gt;Because Next.js only serves widths and qualities listed in &lt;code&gt;next.config.js&lt;/code&gt;, I added &lt;code&gt;q=90&lt;/code&gt; to &lt;code&gt;images.qualities&lt;/code&gt;, and a test now reads that config and fails if any rendered image URL asks for a width or quality the optimizer would reject.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why were the cover banners blurry as well?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;sizes&lt;/code&gt; attribute, which had assumed a 768px column when the header was 1086px wide.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should text be an image and when should it be live text?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The hero of the five mobile app pages is now live HTML in &lt;code&gt;components/projects/app-banner.tsx&lt;/code&gt;: 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist for blurry images and text in CSS
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Search the stylesheet for &lt;code&gt;will-change: transform&lt;/code&gt; and for any &lt;code&gt;rotate()&lt;/code&gt; or &lt;code&gt;scale()&lt;/code&gt; on elements with images or text. Remove them from anything that carries small type.&lt;/li&gt;
&lt;li&gt;Read the computed &lt;code&gt;transform&lt;/code&gt; in devtools. A matrix that is not the identity on a card you thought was flat is a lead.&lt;/li&gt;
&lt;li&gt;Compare the image's intrinsic width with rendered width times device pixel ratio.&lt;/li&gt;
&lt;li&gt;Test at 1x and at 2x. A fix that is sharp at one density can be soft at the other.&lt;/li&gt;
&lt;li&gt;If the blurry thing is text, make it text.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You can see the finished pages on the &lt;a href="https://www.mohamedgado.com/projects/masareef" rel="noopener noreferrer"&gt;Masareef&lt;/a&gt;, &lt;a href="https://www.mohamedgado.com/projects/rafiq" rel="noopener noreferrer"&gt;Rafiq&lt;/a&gt; and &lt;a href="https://www.mohamedgado.com/projects/nafis" rel="noopener noreferrer"&gt;Nafis&lt;/a&gt; case studies. The site they live on is described in &lt;a href="https://www.mohamedgado.com/projects/portfolio" rel="noopener noreferrer"&gt;the portfolio write-up&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>css</category>
      <category>performance</category>
      <category>webdev</category>
      <category>frontend</category>
    </item>
  </channel>
</rss>
