<?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: Shubhra Pokhariya</title>
    <description>The latest articles on DEV Community by Shubhra Pokhariya (@shubhradev).</description>
    <link>https://dev.to/shubhradev</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%2F3462235%2Fa195c0cb-1004-4a1a-93f4-ecb8593c6884.jpg</url>
      <title>DEV Community: Shubhra Pokhariya</title>
      <link>https://dev.to/shubhradev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shubhradev"/>
    <language>en</language>
    <item>
      <title>I Got the proxy.ts Matcher Wrong for Three Projects Before I Understood Why</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Wed, 17 Jun 2026 07:56:06 +0000</pubDate>
      <link>https://dev.to/shubhradev/i-got-the-proxyts-matcher-wrong-for-three-projects-before-i-understood-why-4e5c</link>
      <guid>https://dev.to/shubhradev/i-got-the-proxyts-matcher-wrong-for-three-projects-before-i-understood-why-4e5c</guid>
      <description>&lt;p&gt;A few days ago I published a post about the three-layer auth model and the invoice incident that made me rebuild how I think about Next.js 16 auth. More people had hit the same thing than I expected.&lt;/p&gt;

&lt;p&gt;One comment stopped me. Someone pointed out a real gap in how the forwarded headers work when the matcher misses a route. They were right, and I want to cover it properly here. But before that, I want to go through proxy.ts end to end, because in my experience the matcher is where most auth setups quietly break first, and it breaks in the worst way, no error, no warning, nothing in the logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Name Changed
&lt;/h2&gt;

&lt;p&gt;If you've been building with Next.js for a while, the rename felt arbitrary at first. &lt;code&gt;middleware.ts&lt;/code&gt; to &lt;code&gt;proxy.ts&lt;/code&gt;. Same location in your project, different filename, different export.&lt;/p&gt;

&lt;p&gt;The Next.js team has been direct about why. The word "middleware" created real confusion. Developers coming from Express thought of it as a pipeline, stack things up, run them in order, &lt;code&gt;app.use&lt;/code&gt; everything. That's not what this is and it led to people using it for things it was never meant to do: database calls, heavy business logic, session management.&lt;/p&gt;

&lt;p&gt;What it actually does is sit at the network boundary in front of your app and intercept requests before they reach your routes. That's a proxy. The rename is the team saying: this has a specific job. Stop treating it like a general-purpose request pipeline.&lt;/p&gt;

&lt;p&gt;The official docs are pretty clear:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Proxy is meant to be invoked separately of your render code. You should not attempt relying on shared modules or globals.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No database calls. No heavy logic. That belongs in the layers behind it, which is exactly what the previous post was about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Runtime Change That Actually Matters for Auth
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;middleware.ts&lt;/code&gt; defaulted to the Edge runtime. The Edge runtime had limited crypto support. Verifying JWTs with certain algorithms meant lighter libraries, specific workarounds, and sometimes things that just didn't work depending on which signing algorithm your tokens used.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;proxy.ts&lt;/code&gt; runs on the Node.js runtime by default in Next.js 16. Full crypto support. &lt;code&gt;jose&lt;/code&gt; works completely. Any standard JWT library works. No workarounds.&lt;/p&gt;

&lt;p&gt;From the official version history:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;v16.0.0: Middleware is deprecated and renamed to Proxy. Proxy defaults to the Node.js runtime&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The Node.js runtime in proxy.ts is not configurable. The docs are explicit: the edge runtime is not supported in proxy, the runtime is nodejs, and it cannot be configured. Don't try to change it.&lt;/p&gt;

&lt;p&gt;Edge runtime is still available through &lt;code&gt;middleware.ts&lt;/code&gt;, which still exists in Next.js 16 for edge-specific cases like geographic redirects or A/B testing at the CDN level. But &lt;code&gt;middleware.ts&lt;/code&gt; is deprecated. For auth, you want &lt;code&gt;proxy.ts&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating From middleware.ts
&lt;/h2&gt;

&lt;p&gt;If you haven't done this yet, two options.&lt;/p&gt;

&lt;p&gt;Full upgrade codemod for all Next.js 16 breaking changes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @next/codemod@canary upgrade latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the middleware migration if that's all you need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @next/codemod@canary middleware-to-proxy &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After running either one, check these three things manually. Don't trust the codemod alone:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;proxy.ts&lt;/code&gt; exists at your project root, same level as the &lt;code&gt;app&lt;/code&gt; folder&lt;/li&gt;
&lt;li&gt;The exported function is named &lt;code&gt;proxy&lt;/code&gt;, not &lt;code&gt;middleware&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;middleware.ts&lt;/code&gt; is gone&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That third one is more important than it sounds. If you manually bumped the package version without the codemod, your old &lt;code&gt;middleware.ts&lt;/code&gt; sits there, compiles clean, passes TypeScript checks, and does nothing at runtime. Routes that should be intercepted aren't. Redirects don't fire. No error anywhere. The file is just silently bypassed.&lt;/p&gt;

&lt;p&gt;I covered this in the &lt;a href="https://dev.to/shubhradev/nextjs-16-broke-my-app-in-4-places-and-none-of-them-threw-an-error-51mn"&gt;4 places Next.js 16 broke my app&lt;/a&gt; post. This is the one that hurt the most because everything looks fine until a real redirect fails to fire in staging.&lt;/p&gt;

&lt;p&gt;Also check &lt;code&gt;next.config.js&lt;/code&gt; if you had &lt;code&gt;skipMiddlewareUrlNormalize&lt;/code&gt; — configuration flags containing the middleware name are renamed in Next.js 16, so this is now &lt;code&gt;skipProxyUrlNormalize&lt;/code&gt;. The codemod handles it, but worth verifying manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the proxy.ts Auth Gate
&lt;/h2&gt;

&lt;p&gt;Three decisions in this code that look obvious but aren't. I'll walk through each one after.&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="c1"&gt;// proxy.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&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="nx"&gt;NextRequest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;jwtVerify&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jose&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;// npm install jose&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;JWT_SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PUBLIC_ROUTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/register&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/forgot-password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/reset-password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/auth/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/auth/refresh&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/auth/logout&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/about&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/pricing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ROLE_ROUTES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/dashboard&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;moderator&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/moderator&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;moderator&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;moderator&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="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;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PUBLIC_ROUTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokenCookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auth_tokens&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;tokenCookie&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loginUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;loginUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;redirect&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loginUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokenCookie&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;payload&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="nf"&gt;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;allowedRoles&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ROLE_ROUTES&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;allowedRoles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/unauthorized&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Headers go on the request, not the response&lt;/span&gt;
    &lt;span class="c1"&gt;// Server Components read incoming request headers via headers()&lt;/span&gt;
    &lt;span class="c1"&gt;// Setting them on the response sends them to the browser instead&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestHeaders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;requestHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-user-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;requestHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-user-role&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;requestHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-user-email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requestHeaders&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Expired token, malformed JWT, bad JSON — all redirect to login&lt;/span&gt;
    &lt;span class="c1"&gt;// Same response for all three, no information leak about which failed&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loginUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;loginUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;redirect&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loginUrl&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.png$|.*&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.jpg$|.*&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.webp$|.*&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.svg$|.*&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;.ico$).*)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Decision 1: The Matcher
&lt;/h3&gt;

&lt;p&gt;This is where most auth setups break. And the way it breaks is the worst possible kind.&lt;/p&gt;

&lt;p&gt;Without a matcher, the proxy runs on every request. Every CSS file. Every JS bundle. Every image. That's a JWT verification attempt on each one. When an unauthenticated user hits a CSS request, they get redirected to login, which has no token, which redirects to login again. Infinite redirect loop on static assets. Your app loads for authenticated users but something always feels slow and wrong, and the logs don't explain it.&lt;/p&gt;

&lt;p&gt;The negative lookahead in the matcher above excludes static files and keeps the proxy on actual routes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;_next/static&lt;/code&gt; : compiled JS and CSS bundles&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;_next/image&lt;/code&gt; : image optimization endpoint&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;favicon.ico&lt;/code&gt;, &lt;code&gt;sitemap.xml&lt;/code&gt;, &lt;code&gt;robots.txt&lt;/code&gt; : metadata files&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.*\\.png$&lt;/code&gt; and the other image extensions : public folder assets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One behavior worth knowing: even if you exclude _next/data in your matcher, the proxy still runs for _next/data routes. This is intentional by design. If you protect a page, the proxy deliberately still covers the corresponding data route so you can't accidentally leave it exposed.&lt;/p&gt;

&lt;p&gt;Matcher values must be constants. Statically analyzed at build time. Dynamic values, variables, anything computed gets silently ignored. Another way auth gaps get introduced with zero error output.&lt;/p&gt;

&lt;h3&gt;
  
  
  Decision 2: The Header Direction
&lt;/h3&gt;

&lt;p&gt;I got this backwards the first time I wrote 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="c1"&gt;// This is correct. Headers reach your Server Components&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requestHeaders&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// This is wrong. Headers go to the browser instead&lt;/span&gt;
&lt;span class="c1"&gt;// No error produced&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requestHeaders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;headers()&lt;/code&gt; in a Server Component returns incoming request headers. If you set the &lt;code&gt;x-user-id&lt;/code&gt; header on the response instead of on the forwarded request, every &lt;code&gt;headers().get("x-user-id")&lt;/code&gt; call in your pages returns null. Every authenticated user gets redirected to login. Nothing in the logs to explain it. Took me way longer to debug than it should have.&lt;/p&gt;

&lt;h3&gt;
  
  
  Decision 3: The try/catch
&lt;/h3&gt;

&lt;p&gt;The catch block handles three failure modes with one redirect: the cookie is valid JSON but the tokens object is malformed, the JWT is structurally broken, or the JWT is expired. All three end at login with no indication of which failed.&lt;/p&gt;

&lt;p&gt;Different error responses for different failure modes tell an attacker something about the state of your system. One generic redirect tells them nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Header Trust Boundary
&lt;/h2&gt;

&lt;p&gt;The proxy sets &lt;code&gt;x-user-id&lt;/code&gt; on the request headers before forwarding. Server Components read it with &lt;code&gt;headers().get("x-user-id")&lt;/code&gt;. Works fine when the proxy runs.&lt;/p&gt;

&lt;p&gt;When does the proxy not run? When the matcher has a gap.&lt;/p&gt;

&lt;p&gt;Add a new route, forget to check it falls inside the matcher pattern, the proxy never runs for that route. Nobody set &lt;code&gt;x-user-id&lt;/code&gt;. Now a client sends their own &lt;code&gt;x-user-id: someone_elses_id&lt;/code&gt; header on that unmatched route. The Server Component reads it. From inside the Server Component, a proxy-set header and a client-sent header look identical — there's no way to tell the difference.&lt;/p&gt;

&lt;p&gt;What breaks: if you use that userId to query data, the data layer's &lt;code&gt;AND user_id = $2&lt;/code&gt; still scopes the query correctly. Actual records don't leak. But &lt;code&gt;getUserPermissions(userId)&lt;/code&gt; now runs against the wrong user entirely. The attacker gets a different user's permissions back. No records exposed, but a real authorization failure on a specific route.&lt;/p&gt;

&lt;p&gt;The fix is verifying the JWT directly from the cookie in the Server Component instead of trusting the forwarded header.&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="c1"&gt;// lib/auth-server.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cookies&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/headers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;jwtVerify&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jose&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;JWT_SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;AuthUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;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;getVerifiedUser&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AuthUser&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookieStore&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;cookies&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokenCookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cookieStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auth_tokens&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;tokenCookie&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokenCookie&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;payload&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="nf"&gt;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;Using it in a Server Component:&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="c1"&gt;// app/dashboard/billing/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="nx"&gt;redirect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getVerifiedUser&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/auth-server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getUserPermissions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getUserInvoices&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/data&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;BillingPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&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;getVerifiedUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;permissions&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;getUserPermissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;billing:read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/unauthorized&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoices&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;getUserInvoices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&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="nx"&gt;BillingView&lt;/span&gt; &lt;span class="nx"&gt;invoices&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&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;Yes, this verifies the JWT twice on requests that go through the proxy. The proxy verifies at the network boundary, the Server Component verifies again at render time. That feels redundant. It isn't.&lt;/p&gt;

&lt;p&gt;The proxy verification is the fast gate. The Server Component verification removes the trust dependency on the proxy having run at all. On a route where the proxy ran normally, the second verification takes a few milliseconds. On a route where the matcher had a gap, it's what closes the hole. Trusting a header any client can send on any unmatched route is not worth the few milliseconds saved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server Functions and the Proxy
&lt;/h2&gt;

&lt;p&gt;Something in the official docs tucked into the execution order section that gets missed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Server Functions are not separate routes in this chain. They are handled as POST requests to the route where they are used, so a Proxy matcher that excludes a path will also skip Server Function calls on that path.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your matcher excludes a path, Server Actions on that path run without proxy coverage too. The docs explicitly say to verify authentication and authorization inside each Server Function, not rely on proxy coverage alone.&lt;/p&gt;

&lt;p&gt;Same principle as the three-layer model. The proxy is the fast gate, not the complete answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where proxy.ts Sits in the Request Flow
&lt;/h2&gt;

&lt;p&gt;From the official docs, the actual execution order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;headers&lt;/code&gt; from &lt;code&gt;next.config.js&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;redirects&lt;/code&gt; from &lt;code&gt;next.config.js&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;proxy.ts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;beforeFiles&lt;/code&gt; rewrites from &lt;code&gt;next.config.js&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Filesystem routes (&lt;code&gt;public/&lt;/code&gt;, &lt;code&gt;_next/static/&lt;/code&gt;, &lt;code&gt;pages/&lt;/code&gt;, &lt;code&gt;app/&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;afterFiles&lt;/code&gt; rewrites from &lt;code&gt;next.config.js&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Dynamic routes&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fallback&lt;/code&gt; rewrites from &lt;code&gt;next.config.js&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Third. After &lt;code&gt;next.config.js&lt;/code&gt; headers and redirects, before anything in the filesystem renders. That's why it's cheap, it intercepts before any React component work starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Things to Check Before You Ship
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;JWT_SECRET in every environment.&lt;/strong&gt; Always in &lt;code&gt;.env.local&lt;/code&gt;. Easy to forget in staging or production. The &lt;code&gt;jwtVerify&lt;/code&gt; call throws an opaque error when it's missing and authenticated users get sent to login with nothing in the logs that explains why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Actually verify the migration ran.&lt;/strong&gt; Check that &lt;code&gt;proxy.ts&lt;/code&gt; exists at project root, the export is named &lt;code&gt;proxy&lt;/code&gt;, and &lt;code&gt;middleware.ts&lt;/code&gt; is deleted. Running the codemod and assuming it worked is not the same as checking. Manual upgrade with no codemod means the old file sits there silently doing nothing with zero warning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the matcher every time you add a protected route.&lt;/strong&gt; New route, check it falls inside the matcher pattern, check it appears in &lt;code&gt;ROLE_ROUTES&lt;/code&gt;. Most auth gaps I've seen get introduced weeks after the initial proxy setup when someone adds a route and nobody goes back to verify coverage.&lt;/p&gt;

&lt;p&gt;The proxy is the fast gate. It's essential and does its job well at the network boundary.&lt;/p&gt;

&lt;p&gt;It can't answer ownership questions. It can't stop one user from seeing another user's data. And the forwarded header pattern has a real trust boundary issue on any route where the matcher has a gap.&lt;/p&gt;

&lt;p&gt;Next post covers the Server Component authorization layer, roles versus permissions, why permissions need a database call that roles don't, and how the independent check at render time catches what the proxy structurally cannot.&lt;/p&gt;

&lt;p&gt;Full implementation with all three layers: &lt;a href="https://shubhra.dev/tutorials/nextjs-16-authentication-3-layer-security" rel="noopener noreferrer"&gt;shubhra.dev/tutorials/nextjs-16-authentication-3-layer-security&lt;/a&gt;. The proxy setup in this post maps to Step 2 there. The &lt;code&gt;getVerifiedUser&lt;/code&gt; utility above updates the Server Component pattern in Step 3 to close the header trust boundary gap.&lt;/p&gt;

&lt;p&gt;Note: I use AI for editing and structure, but the technical substance is from my own work.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>I Thought My Next.js 16 Auth Was Solid. One Afternoon Proved Otherwise.</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Wed, 10 Jun 2026 07:04:58 +0000</pubDate>
      <link>https://dev.to/shubhradev/i-thought-my-nextjs-16-auth-was-solid-one-afternoon-proved-otherwise-4dhc</link>
      <guid>https://dev.to/shubhradev/i-thought-my-nextjs-16-auth-was-solid-one-afternoon-proved-otherwise-4dhc</guid>
      <description>&lt;p&gt;A few months ago I was doing a final pre-release review on a client project before handing it over.&lt;/p&gt;

&lt;p&gt;Protected routes were protected. Unauthenticated users got redirected. Tokens expired correctly. I had tested it three different ways and everything held up. I was confident enough to stop thinking about it.&lt;/p&gt;

&lt;p&gt;Then I found something.&lt;/p&gt;

&lt;p&gt;A user with a valid session, the right role, and a correct JWT could still request another user's invoice if they knew the ID. No error in the logs. No failed requests. The auth was technically correct at every layer I had actually built.&lt;/p&gt;

&lt;p&gt;The problem was I had only built one layer and called it done.&lt;/p&gt;

&lt;p&gt;That afternoon changed how I think about Next.js 16 authentication entirely. And I have been building it differently ever since.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Thing to Check Before Writing a Line of Auth Code
&lt;/h2&gt;

&lt;p&gt;If you are coming from Next.js 15, &lt;code&gt;middleware.ts&lt;/code&gt;  is deprecated in Next.js 16 and replaced by &lt;code&gt;proxy.ts&lt;/code&gt;. Same location in your project, different filename, different exported function name. &lt;code&gt;middleware.ts&lt;/code&gt; still works for Edge runtime use cases but will be removed in a future version.&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="c1"&gt;// Before: middleware.ts&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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&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="c1"&gt;// After: proxy.ts&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;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&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 auth-relevant change is the runtime. &lt;code&gt;middleware.ts&lt;/code&gt; defaulted to Edge runtime, which had limited crypto support. Verifying JWTs in Edge required lighter libraries and sometimes workarounds depending on which algorithms your tokens used.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;proxy.ts&lt;/code&gt; runs on Node.js runtime in Next.js 16. &lt;code&gt;jose&lt;/code&gt; works completely. Any standard JWT library works. It removes the Node-crypto limitations that existed in the Edge runtime, which is a massive improvement for auth specifically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Handles the rename and all other Next.js 16 breaking changes&lt;/span&gt;
npx @next/codemod@canary upgrade latest

&lt;span class="c"&gt;# Only the middleware migration if that is all you need&lt;/span&gt;
npx @next/codemod@canary middleware-to-proxy &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After running it: verify &lt;code&gt;proxy.ts&lt;/code&gt; exists in your project root and the exported function is named &lt;code&gt;proxy&lt;/code&gt;. Remove &lt;code&gt;middleware.ts&lt;/code&gt; to avoid ambiguity. While it is still supported for Edge runtime, it is deprecated in Next.js 16 and the framework expects &lt;code&gt;proxy.ts&lt;/code&gt; going forward.&lt;br&gt;
Keeping both files can lead to confusion about which one is actually handling requests.&lt;/p&gt;

&lt;p&gt;One important constraint: &lt;code&gt;proxy.ts&lt;/code&gt; is designed for redirects, rewrites, header modifications, and direct responses. It is not intended for slow data fetching or full session management, and should not be used as your primary authorization layer. This is by design to keep the proxy focused on fast network boundary concerns.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why proxy.ts Is Not Your Security Layer (Even Though It Looks Like One)
&lt;/h2&gt;

&lt;p&gt;This is the thing almost every Next.js auth tutorial gets wrong. Not in an obvious way. In the way that passes all your tests and breaks in production.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;proxy.ts&lt;/code&gt; runs at the network boundary before anything renders. It reads your session cookie, verifies the JWT, checks the role, and redirects or continues. Fast, essential, genuinely useful. Every auth setup needs it.&lt;/p&gt;

&lt;p&gt;But it only sees URL patterns.&lt;/p&gt;

&lt;p&gt;When it decided my user could access &lt;code&gt;/dashboard/invoices&lt;/code&gt;, its job was done. It had no idea which specific invoice ID was in the URL, who owned that invoice, or whether the person requesting it had any right to see it. From the proxy's point of view, the URL matched, the role matched, the token was valid. Green light.&lt;/p&gt;

&lt;p&gt;That gap is the invoice incident. The proxy was correct. It was just correct about the wrong thing.&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="c1"&gt;// proxy.ts: role-based route protection, correct and valuable&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ROLE_ROUTES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;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="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/dashboard&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;moderator&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// The proxy sees /dashboard/invoices/abc123 and approves it&lt;/span&gt;
&lt;span class="c1"&gt;// It has no way to know if abc123 belongs to this user&lt;/span&gt;
&lt;span class="c1"&gt;// Not a proxy bug. A category mismatch.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proxy cannot answer ownership questions. Nobody can answer ownership questions from a URL pattern alone. You need the database for that. And the database is not where the proxy runs.&lt;/p&gt;

&lt;p&gt;Treating &lt;code&gt;proxy.ts&lt;/code&gt; as the complete security layer is not a configuration mistake. It is a mental model mistake. And it is the one almost every auth tutorial makes by stopping there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Check That Runs Even When Your Proxy Has a Gap
&lt;/h2&gt;

&lt;p&gt;Server Components render when the page renders. That sounds obvious, but the implication is worth sitting with: Server Components run after the proxy and act as a second layer of enforcement. Even if a route slips past a misconfigured proxy matcher gap, this check still runs.&lt;/p&gt;

&lt;p&gt;This is also where you catch the thing the proxy structurally cannot catch: permission-level decisions that depend on your database.&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/dashboard/billing/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="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/headers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;redirect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getUserPermissions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getUserInvoices&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/data&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;BillingPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headerStore&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;headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;headerStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-user-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;// This check runs even if the proxy had a gap on this route&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&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="c1"&gt;// Role came from the JWT, fast, no DB call needed&lt;/span&gt;
  &lt;span class="c1"&gt;// Permissions need a DB call because they change more often than roles&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;permissions&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;getUserPermissions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;billing:read&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/unauthorized&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoices&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;getUserInvoices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BillingView&lt;/span&gt; &lt;span class="na"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;invoices&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The split between roles and permissions is intentional. Roles are stable enough to embed in a JWT. The proxy reads them without touching the database. Permissions change when you update access settings and you do not want to wait for a JWT to expire before that change takes effect. Roles in the proxy, permissions in the Server Component. That boundary matters.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;x-user-id&lt;/code&gt; header gets there because the proxy sets it before forwarding the request:&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="c1"&gt;// Inside proxy.ts after JWT verification&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestHeaders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;requestHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-user-id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;requestHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-user-role&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requestHeaders&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// Headers go on the request, not the response&lt;/span&gt;
&lt;span class="c1"&gt;// Server Components read incoming request headers via headers()&lt;/span&gt;
&lt;span class="c1"&gt;// Setting them on the response sends them to the browser instead&lt;/span&gt;
&lt;span class="c1"&gt;// Your pages never see them if you get this backwards&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I got this backwards the first time I wrote it. No error anywhere. The &lt;code&gt;headers()&lt;/code&gt; call in the Server Component just returned null for both fields and the page redirected everyone to &lt;code&gt;/login&lt;/code&gt; including fully authenticated admins. Spent an embarrassing amount of time on that one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Backstop That Would Have Caught the Invoice Bug No Matter What
&lt;/h2&gt;

&lt;p&gt;Even if the proxy had a gap and the Server Component had a gap on the same day, this layer still holds.&lt;/p&gt;

&lt;p&gt;Every data function that returns user-specific data takes a &lt;code&gt;userId&lt;/code&gt; and uses it inside the query. Not as a convenience. As the actual access control.&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="c1"&gt;// lib/data.ts&lt;/span&gt;

&lt;span class="c1"&gt;// This is what my data layer looked like before the incident&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;getInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM invoices WHERE id = $1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;invoiceId&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="c1"&gt;// Anyone with any valid session could call this with any ID&lt;/span&gt;
&lt;span class="c1"&gt;// No error. No log entry. Just data returned to whoever asked.&lt;/span&gt;

&lt;span class="c1"&gt;// This is what it looks like now&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;getInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM invoices WHERE id = $1 AND user_id = $2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&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="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Not found&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same error for "not found" and "not authorized" is intentional. Different errors let an attacker enumerate which IDs exist in your system. One generic error closes that information leak.&lt;/p&gt;

&lt;p&gt;The ownership check lives inside the SQL. It cannot be misconfigured away. It cannot be missing from a route someone added last week. It cannot have a matcher gap. The authorization is in the query itself and the query runs every single time.&lt;/p&gt;

&lt;p&gt;This is the layer that was missing from my app. The other two were correct. This one did not exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What All Three Layers Look Like on One Request
&lt;/h2&gt;

&lt;p&gt;When an authenticated user hits &lt;code&gt;/dashboard/invoices/abc123&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request arrives

proxy.ts
  Reads auth_tokens cookie (written by your auth API on login)
  Verifies JWT (full Node.js runtime in Next.js 16, jose works completely)
  Checks role against ROLE_ROUTES
  Wrong role? Redirect to /unauthorized
  Correct role? Set x-user-id, x-user-role on request headers, continue

app/dashboard/invoices/[id]/page.tsx
  Reads x-user-id from forwarded headers, no re-verification needed
  Checks billing:read permission against DB
  Missing permission? redirect('/unauthorized')
  Has permission? Call getInvoice(id, userId)

lib/data.ts: getInvoice(invoiceId, userId)
  SELECT * FROM invoices WHERE id = $1 AND user_id = $2
  No row? throw new Error('Not found')
  Row exists? User owns it. Return data.

Component renders.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three independent checks. A bug at any one of them does not open a door because the other two are still running.&lt;/p&gt;

&lt;p&gt;One setup requirement worth knowing before you build this: the proxy reads the session from a cookie. If your auth client stores tokens in localStorage by default, the proxy cannot see them at all and will redirect every authenticated request straight to login. Make sure your auth setup writes tokens to a cookie. The proxy template in the full implementation reads a cookie named &lt;code&gt;auth_tokens&lt;/code&gt; set on login.&lt;/p&gt;

&lt;p&gt;The invoice incident had no bug in the proxy. No bug in the Server Component. One missing &lt;code&gt;AND user_id = $2&lt;/code&gt; in a data function. That was the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mental Model in One Sentence
&lt;/h2&gt;

&lt;p&gt;The proxy decides who can reach a route. The Server Component decides who can render a page. The data layer decides who can see a record.&lt;/p&gt;

&lt;p&gt;All three are required. Any one of them alone will eventually have a gap. Two of them will eventually have a gap on the same day and you will be glad the third one exists.&lt;/p&gt;

&lt;p&gt;The invoice incident was not a proxy failure. Not a Server Component failure. It was a missing data layer. The first two layers were correct. The third one did not exist.&lt;/p&gt;

&lt;p&gt;That is the auth system most Next.js tutorials hand you. One layer. The other two are never mentioned.&lt;/p&gt;

&lt;p&gt;This post is part of the &lt;strong&gt;Next.js 16 Auth: From Broken to Production-Safe&lt;/strong&gt; series. The next post covers building the complete &lt;code&gt;proxy.ts&lt;/code&gt; gate with working code: JWT verification with &lt;code&gt;jose&lt;/code&gt;, the role routing config, the matcher pattern that most setups get wrong, and why the header forwarding direction breaks silently when you get it backwards.&lt;/p&gt;

&lt;p&gt;The full implementation with all three layers, token refresh, Route Handler protection, and the complete Auth Guard client-side setup is at &lt;a href="https://shubhra.dev/tutorials/nextjs-16-authentication-3-layer-security" rel="noopener noreferrer"&gt;shubhra.dev/tutorials/nextjs-16-authentication-3-layer-security&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Has anyone else hit a data layer gap like this? I kept assuming the proxy covered everything until it very clearly did not. Curious how common this actually is across different projects.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>security</category>
    </item>
    <item>
      <title>After 7 Next.js 16 Caching Bugs, I Stopped Guessing and Built a System</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Wed, 03 Jun 2026 05:09:01 +0000</pubDate>
      <link>https://dev.to/shubhradev/after-7-nextjs-16-caching-bugs-i-stopped-guessing-and-built-a-system-4ijp</link>
      <guid>https://dev.to/shubhradev/after-7-nextjs-16-caching-bugs-i-stopped-guessing-and-built-a-system-4ijp</guid>
      <description>&lt;p&gt;There's a specific feeling you get after your third production caching incident.&lt;/p&gt;

&lt;p&gt;It's not panic. It's worse than panic. It's that quiet realisation that you fixed the last bug correctly, and you still have no idea where the next one is hiding.&lt;/p&gt;

&lt;p&gt;After the &lt;a href="https://dev.to/shubhradev/7-nextjs-16-caching-bugs-that-compile-fine-and-break-silently-in-production-1cap"&gt;7 silent caching bugs post&lt;/a&gt;, a pattern kept coming up in the comments. Everyone understood what was breaking, but not what the correct setup should look like. This is that answer.&lt;/p&gt;

&lt;p&gt;Not theory. The actual system I use now, after getting burned enough times to understand why each piece exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first problem: tag strings written from memory in different files
&lt;/h2&gt;

&lt;p&gt;Most silent cache bugs in Next.js 16 start here. Not because anyone is being careless. Because there is nothing stopping two people from writing two different strings that should be the same.&lt;/p&gt;

&lt;p&gt;Developer A writes the data function on Monday:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getProducts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="nf"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product-list&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Developer B writes the mutation two weeks later in a different file:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProductData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO products ...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[...])&lt;/span&gt;
  &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;product-list&lt;/code&gt; and &lt;code&gt;products&lt;/code&gt;. Two different strings. Zero errors from TypeScript, zero warnings from Next.js. The product list never refreshes after a new product is created and nobody knows why until someone reads both files at the same time.&lt;/p&gt;

&lt;p&gt;This is Bug 3 from the previous post. I kept hitting variations of it across different parts of the codebase even after I knew about it, because knowing about a problem and having something that prevents it are not the same thing.&lt;/p&gt;

&lt;p&gt;The fix is one file that owns all your tag strings:&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="c1"&gt;// lib/tags.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`product-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`user-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;productList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;userList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;navigation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now both files import from &lt;code&gt;tags&lt;/code&gt;. A typo is a TypeScript compile error. The string mismatch bug cannot happen. Everyone on the team gets autocomplete instead of muscle memory.&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="c1"&gt;// data function&lt;/span&gt;
&lt;span class="nf"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productList&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// mutation — same import, same string, guaranteed&lt;/span&gt;
&lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productList&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the single change that removed the most bugs from my codebase, by far. Set it up before you write a single cached function on a new project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second problem: three different places to invalidate cache, three different correct APIs
&lt;/h2&gt;

&lt;p&gt;This is where I see the most confusion, including in my own early code. The API you reach for depends entirely on where you're calling from and what the user needs to see. Get it wrong and you either throw at runtime or silently give someone stale data.&lt;/p&gt;

&lt;p&gt;Here is how I think about it now:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inside a Server Action where the user who just made a change needs to see it immediately:&lt;/strong&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;updateTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;revalidateTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateProductPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UPDATE products SET price = $1 WHERE id = $2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;newPrice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

  &lt;span class="nf"&gt;updateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;product&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;               &lt;span class="c1"&gt;// acting user sees fresh data right away&lt;/span&gt;
  &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;product&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;// everyone else gets SWR update&lt;/span&gt;
  &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productList&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;// product list refreshes too&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order matters here. &lt;code&gt;updateTag&lt;/code&gt; runs first. This is what prevents the admin from clicking save, navigating back to the product page, and seeing the old price. That looks like the save failed. It causes people to click save again. &lt;code&gt;updateTag&lt;/code&gt; fixes it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;updateTag&lt;/code&gt; is Server Actions only. Calling it anywhere else throws at runtime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inside a Route Handler (webhooks, external services):&lt;/strong&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="c1"&gt;// app/api/webhooks/stripe/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;revalidateTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&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;parseStripeWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price.updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productList&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expire&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;updateTag&lt;/code&gt; is not available in Route Handlers. &lt;code&gt;{ expire: 0 }&lt;/code&gt; is the equivalent for immediate expiry here. This is what you want for webhooks where a third-party system just told you something changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Background updates where a brief stale window is fine:&lt;/strong&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="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productList&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stale-while-revalidate. Users get a fast cached response while fresh data loads behind the scenes. For most content this is exactly right. An admin publishes a new post, readers might see the old list for a moment, that is usually acceptable.&lt;/p&gt;

&lt;p&gt;Here is the whole thing as a decision table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;User edits their own data and needs to see it immediately&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;updateTag&lt;/code&gt; then &lt;code&gt;revalidateTag&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Webhook fires, third-party service needs immediate consistency&lt;/td&gt;
&lt;td&gt;&lt;code&gt;revalidateTag(tag, { expire: 0 })&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background refresh, brief stale window is acceptable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;revalidateTag(tag, 'max')&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Write this down somewhere your team can see it. Saves a lot of "why is the user seeing old data after saving" conversations.&lt;/p&gt;

&lt;h2&gt;
  
  
  The third problem: the PPR split is invisible by default
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;cacheComponents: true&lt;/code&gt;, Next.js uses Partial Prerendering. Your page has a static shell that renders instantly from cache and dynamic holes that stream in after. The performance win is real. The problem is that what ends up in the shell versus what ends up as a dynamic hole is not obvious until something behaves wrong.&lt;/p&gt;

&lt;p&gt;One component with &lt;code&gt;cacheLife('seconds')&lt;/code&gt; gets quietly excluded from the static shell. A &lt;code&gt;cookies()&lt;/code&gt; call inside a cached scope throws at build time with "Uncached data was accessed outside of Suspense" and gives you no component name, no file path, nothing useful. A dynamic component added without a Suspense boundary pushes part of the page out of the shell.&lt;/p&gt;

&lt;p&gt;The way I stopped guessing about this is to document intent at the component level:&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="c1"&gt;// components/UserCart.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="nx"&gt;boundary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UserCart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;isDynamic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Reads user session cookie — different per user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in the page that uses it, I reference that intent explicitly:&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="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="nf"&gt;ProductPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&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="nl"&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;

  &lt;span class="c1"&gt;// UserCart is dynamic — must be in Suspense or it breaks the static shell&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProductDetails&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;       &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* cached, part of static shell */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RelatedProducts&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* cached, part of static shell */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CartSkeleton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UserCart&lt;/span&gt; &lt;span class="na"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* dynamic, streams in after */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&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 cached components look like this:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductDetails&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="nf"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hours&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;product&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM products WHERE id = $1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&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="nx"&gt;article&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/article&lt;/span&gt;&lt;span class="err"&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;The dynamic component has no &lt;code&gt;'use cache'&lt;/code&gt; at all:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UserCart&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookieStore&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;cookies&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cookieStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cartItem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM cart WHERE user_id = $1 AND product_id = $2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cartItem&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;InCartButton&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AddToCartButton&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;Static shell hits the user instantly. Cart streams in after. The split is intentional and documented, not whatever survived the algorithm.&lt;/p&gt;

&lt;p&gt;One more thing on this: never call &lt;code&gt;cookies()&lt;/code&gt;, &lt;code&gt;headers()&lt;/code&gt;, or &lt;code&gt;draftMode()&lt;/code&gt; inside a &lt;code&gt;'use cache'&lt;/code&gt; scope. Read them outside, pass the values as props. Those values become part of the cache key automatically — different users produce separate cache entries without you doing anything extra.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fourth problem: cold starts hurt the first visitor after every deploy
&lt;/h2&gt;

&lt;p&gt;This one is separate from the bugs but connects to the same goal. Your caching is set up correctly. You deploy. The first visitor hits the page and every cached function runs from scratch sequentially because the cache is empty.&lt;/p&gt;

&lt;p&gt;PPR is fast once the cache is warm. That first request after a deploy is not.&lt;/p&gt;

&lt;p&gt;The fix is React's &lt;code&gt;cache()&lt;/code&gt; for request-level deduplication. Fire all your data fetches in parallel at the top of the page before any component needs them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getProductById&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getRelatedProducts&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/data&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prefetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;getProductById&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;related&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;getRelatedProducts&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="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="nf"&gt;ProductPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&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="nl"&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;

  &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;prefetch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;product&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;prefetch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ProductDetails&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;RelatedProducts&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Suspense&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CartSkeleton&lt;/span&gt; &lt;span class="o"&gt;/&amp;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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;UserCart&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Suspense&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&lt;/span&gt;&lt;span class="err"&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;Both fetches fire immediately in parallel. Child components that call the same functions get deduplicated results from React's &lt;code&gt;cache()&lt;/code&gt;. If a prefetch fails it fails silently. It is an optimisation, not a requirement. The actual fetching in child components still works.&lt;/p&gt;

&lt;p&gt;The distinction worth knowing: React's &lt;code&gt;cache()&lt;/code&gt; deduplicates within a single request. &lt;code&gt;'use cache'&lt;/code&gt; persists across requests. You need both, they solve different problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the full system looks like
&lt;/h2&gt;

&lt;p&gt;One tags file. Everyone imports from it. A typo is a compile error, not a production incident.&lt;/p&gt;

&lt;p&gt;A clear decision for invalidation context: Server Action with a user waiting for their change uses &lt;code&gt;updateTag&lt;/code&gt; first, then &lt;code&gt;revalidateTag&lt;/code&gt;. Route Handler uses &lt;code&gt;revalidateTag&lt;/code&gt; with &lt;code&gt;{ expire: 0 }&lt;/code&gt;. Background broadcast uses &lt;code&gt;revalidateTag&lt;/code&gt; with &lt;code&gt;'max'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Dynamic components documented and always wrapped in Suspense. The static shell is explicit, not accidental.&lt;/p&gt;

&lt;p&gt;Prefetch fired in parallel at the top of heavy pages so the first visitor after a deploy is not the one paying the cold start cost.&lt;/p&gt;

&lt;p&gt;None of this is complicated once you have it written down. The hard part was figuring out that I needed all of it, which took enough production bugs to see the pattern.&lt;/p&gt;

&lt;p&gt;The earlier posts in this series cover how I got here. &lt;a href="https://dev.to/shubhradev/i-built-a-free-debugger-because-nextjs-16-use-cache-was-completely-invisible-during-development-4a8"&gt;Building the debugger&lt;/a&gt; when development was a black box. &lt;a href="https://dev.to/shubhradev/7-nextjs-16-caching-bugs-that-compile-fine-and-break-silently-in-production-1cap"&gt;The seven bugs&lt;/a&gt; that compile and break silently. &lt;a href="https://dev.to/shubhradev/nextjs-16-broke-my-app-in-4-places-and-none-of-them-threw-an-error-51mn"&gt;The upgrade breaks that the build never warns you about&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you want the full migration reference, I wrote that at &lt;a href="https://shubhra.dev/tutorials/nextjs-16-cache-components" rel="noopener noreferrer"&gt;shubhra.dev/tutorials/nextjs-16-cache-components&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I kept hitting these edge cases often enough that I eventually pulled the whole system into a single utility. The &lt;a href="https://shubhra.dev/snippets/nextjs-cache-pro" rel="noopener noreferrer"&gt;Cache Pro Kit&lt;/a&gt; is the production version of everything in this post. Type-safe tag registry, &lt;code&gt;safeRevalidate&lt;/code&gt; that blocks the single-arg call at compile time, &lt;code&gt;serverActionInvalidate&lt;/code&gt; that enforces the correct order, &lt;code&gt;routeHandlerInvalidate&lt;/code&gt; so &lt;code&gt;updateTag&lt;/code&gt; in a Route Handler is impossible. One file, drop into &lt;code&gt;lib/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What does your caching setup look like right now? Have you hit any of these in your own projects?&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>Next.js 16 Broke My App in 4 Places and None of Them Threw an Error</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Wed, 27 May 2026 08:32:54 +0000</pubDate>
      <link>https://dev.to/shubhradev/nextjs-16-broke-my-app-in-4-places-and-none-of-them-threw-an-error-51mn</link>
      <guid>https://dev.to/shubhradev/nextjs-16-broke-my-app-in-4-places-and-none-of-them-threw-an-error-51mn</guid>
      <description>&lt;p&gt;The CI was green.&lt;/p&gt;

&lt;p&gt;Build passed. No TypeScript errors. No warnings. Everything looked clean. I clicked deploy and went to make tea.&lt;/p&gt;

&lt;p&gt;Came back, opened staging, and things were broken in ways that made no sense. A redirect wasn't working. Lint had silently disappeared from the build pipeline. One API route was throwing on the very first real request. And a revalidation call I'd written two weeks earlier was running but doing nothing.&lt;/p&gt;

&lt;p&gt;Not one of these showed up during the build. Everything looked completely fine until it wasn't.&lt;/p&gt;

&lt;p&gt;This is what actually happened during my Next.js 16 upgrade, and what to check before you ship yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;middleware.ts&lt;/code&gt; stopped running and told me nothing
&lt;/h2&gt;

&lt;p&gt;My middleware file was fine. It compiled. The export was valid. TypeScript was happy.&lt;/p&gt;

&lt;p&gt;After upgrading to Next.js 16, it just stopped running on requests. No error. No deprecation warning. No sign of anything wrong in the terminal. The file was simply ignored.&lt;/p&gt;

&lt;p&gt;What happened: Next.js 16 replaced &lt;code&gt;middleware.ts&lt;/code&gt; with &lt;code&gt;proxy.ts&lt;/code&gt;. Same location in your project. Different filename. Different exported function name.&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="c1"&gt;// Before: middleware.ts&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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/home&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="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 typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After: proxy.ts&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;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/home&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole change. File rename, function rename. But because the old file didn't throw anything, I assumed it was still running. I only caught it because a redirect I expected wasn't happening and I spent way too long looking at the wrong thing.&lt;/p&gt;

&lt;p&gt;One thing to know: if you need edge runtime behavior specifically, &lt;code&gt;middleware.ts&lt;/code&gt; still exists for that use case. In my case, the logic I had there stopped running after the upgrade. Renaming the file and export fixed it immediately. The codemod handles this automatically. But if you manually upgraded the package without running it, or if it missed a file, this one is completely invisible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before you ship:&lt;/strong&gt; rename the file, rename the export, test a route that depends on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;revalidateTag('products')&lt;/code&gt; compiled, deployed, and silently did the wrong thing
&lt;/h2&gt;

&lt;p&gt;During the migration I wrote this:&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="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One argument. Totally normal in Next.js 15. I'd written it a couple of weeks earlier and hadn't thought about it since.&lt;/p&gt;

&lt;p&gt;In Next.js 16, the single-argument form is deprecated and produces a TypeScript error. But only if your &lt;code&gt;tsconfig&lt;/code&gt; is in strict mode. Mine wasn't. It had been set up on an older project years ago and never touched.&lt;/p&gt;

&lt;p&gt;So it compiled. It deployed. It ran. And it fell back to legacy invalidation behavior instead of the new SWR-based system. Pages weren't reflecting mutations. No error anywhere, just stale data that I attributed to other things for longer than I should have.&lt;/p&gt;

&lt;p&gt;The fix is just adding the second argument:&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="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// SWR, the recommended default&lt;/span&gt;
&lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expire&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="c1"&gt;// Immediate expiry, for webhooks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The codemod (&lt;code&gt;npx @next/codemod@canary upgrade latest&lt;/code&gt;) handles this. But if you wrote any revalidation calls after upgrading, or if the codemod missed a file, check manually.&lt;/p&gt;

&lt;p&gt;The real fix is turning on strict mode in your &lt;code&gt;tsconfig&lt;/code&gt;. That one change makes this a compile error instead of a silent runtime problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"strict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do it before anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;next lint&lt;/code&gt; disappeared and my CI kept saying it passed
&lt;/h2&gt;

&lt;p&gt;This one sounds minor. It wasn't.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;next lint&lt;/code&gt; is completely removed in Next.js 16. Not deprecated. Not changed. Gone. The &lt;code&gt;eslint&lt;/code&gt; option in &lt;code&gt;next.config.ts&lt;/code&gt; is also gone. &lt;code&gt;next build&lt;/code&gt; no longer runs linting automatically.&lt;/p&gt;

&lt;p&gt;My CI was configured to run &lt;code&gt;next lint&lt;/code&gt; as a step. After the upgrade, that command no longer existed. Depending on how your CI handles missing commands, it might fail loudly or it might just succeed silently and move on. Mine moved on.&lt;/p&gt;

&lt;p&gt;So I was shipping code with no linting running, and the CI was reporting green. I only noticed when an obvious issue slipped through that I expected lint to catch.&lt;/p&gt;

&lt;p&gt;The migration is to run ESLint directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eslint ."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"lint:fix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eslint . --fix"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The codemod creates &lt;code&gt;eslint.config.mjs&lt;/code&gt; and updates your package.json scripts. But your CI config is a separate file the codemod does not touch. Check both places.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. One component was still reading &lt;code&gt;params&lt;/code&gt; synchronously
&lt;/h2&gt;

&lt;p&gt;The codemod updated most of my pages correctly. But I had a layout file it missed. The component was accessing &lt;code&gt;params&lt;/code&gt; directly without awaiting it, which is fine in Next.js 15 but wrong in 16 where &lt;code&gt;params&lt;/code&gt; is now a Promise.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before — Next.js 15&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="nf"&gt;Layout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// After — Next.js 16&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="nf"&gt;Layout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&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="nl"&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one did throw, but only on the first real request to that route in staging, not during the build. The build passed completely clean.&lt;/p&gt;

&lt;p&gt;If you have layouts, pages, or route handlers, search the whole codebase for direct &lt;code&gt;params.&lt;/code&gt; access and check that every one has been updated. Same goes for &lt;code&gt;searchParams&lt;/code&gt;, &lt;code&gt;cookies()&lt;/code&gt;, &lt;code&gt;headers()&lt;/code&gt;, and &lt;code&gt;draftMode()&lt;/code&gt;. All async now, all need awaiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that connects all four
&lt;/h2&gt;

&lt;p&gt;None of these are caching bugs. They're upgrade bugs. The kind where the build passes, the code is technically valid, and the wrong behavior only shows up under a specific condition: a real redirect being triggered, a mutation needing to reflect, a lint issue reaching review, a specific route being hit.&lt;/p&gt;

&lt;p&gt;The codemod gets most of this. Run &lt;code&gt;npx @next/codemod@canary upgrade latest&lt;/code&gt; before you change anything else. Then check three things manually: grep for any &lt;code&gt;revalidateTag(&lt;/code&gt; with a single argument, check your CI config for &lt;code&gt;next lint&lt;/code&gt;, and turn on strict TypeScript. Those three cover most of what the codemod can miss.&lt;/p&gt;

&lt;p&gt;If you're already past the upgrade and dealing with caching behavior specifically, the previous posts in this series cover that. &lt;a href="https://dev.to/shubhradev/i-built-a-free-debugger-because-nextjs-16-use-cache-was-completely-invisible-during-development-4a8"&gt;The debugger I built to make cache behavior visible during development&lt;/a&gt; and &lt;a href="https://dev.to/shubhradev/7-nextjs-16-caching-bugs-that-compile-fine-and-break-silently-in-production-1cap"&gt;the seven bugs that compile clean and break silently in production&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I also have a full step-by-step migration guide with before/after comparisons at &lt;a href="https://shubhra.dev/tutorials/nextjs-16-cache-components" rel="noopener noreferrer"&gt;shubhra.dev/tutorials/nextjs-16-cache-components&lt;/a&gt; if you want the complete reference.&lt;/p&gt;

&lt;p&gt;Which of these hit you? Or something I didn't mention here?&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>7 Next.js 16 Caching Bugs That Compile Fine and Break Silently in Production</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Thu, 21 May 2026 12:06:06 +0000</pubDate>
      <link>https://dev.to/shubhradev/7-nextjs-16-caching-bugs-that-compile-fine-and-break-silently-in-production-1cap</link>
      <guid>https://dev.to/shubhradev/7-nextjs-16-caching-bugs-that-compile-fine-and-break-silently-in-production-1cap</guid>
      <description>&lt;p&gt;I lost hours debugging a Next.js 16 caching issue that had no error, no warning, and only showed up in production.&lt;/p&gt;

&lt;p&gt;The Next.js 16 caching model is genuinely good. But it introduces a class of bugs that are harder to detect than anything in previous versions: bugs that look correct, compile without errors, deploy successfully, and then silently misbehave in production.&lt;/p&gt;

&lt;p&gt;These are the most common ones I've seen across real projects. Every one comes from real production incidents.&lt;/p&gt;

&lt;p&gt;(Assumes you have cacheComponents: true enabled in next.config.ts.)&lt;/p&gt;

&lt;p&gt;This is a follow-up to my &lt;a href="https://dev.to/shubhradev/i-built-a-free-debugger-because-nextjs-16-use-cache-was-completely-invisible-during-development-4a8"&gt;previous post&lt;/a&gt; where I built a dev-only debugger to surface these issues during development. That tool helps you detect them. This post breaks down the exact failure cases behind those warnings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: &lt;code&gt;'use cache'&lt;/code&gt; on the Wrapper Instead of Inside the Function
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This looks cached. It is not.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getProducts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;someWrapper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="nf"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hours&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;'use cache'&lt;/code&gt; directive tells the Next.js compiler to treat that function as a cache boundary. When you wrap it, the compiler sees the wrapper as the entry point. The inner function may be cached, but the wrapper becomes the execution boundary, so you still end up running it on every request.&lt;/p&gt;

&lt;p&gt;No error. No warning. Just a function running on every request when it should be cached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;_getProducts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="nf"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hours&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Wrapper receives the already-cached function&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getProducts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;someWrapper&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_getProducts&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 is simple: &lt;code&gt;'use cache'&lt;/code&gt; lives inside the data function, never on anything that wraps it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: Deprecated &lt;code&gt;revalidateTag&lt;/code&gt; That Compiles and Uses Legacy Behavior
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Next.js 15: correct&lt;/span&gt;
&lt;span class="c1"&gt;// Next.js 16: TypeScript error, silently uses legacy behavior in loose tsconfig&lt;/span&gt;
&lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Next.js 16, &lt;code&gt;revalidateTag&lt;/code&gt; without a second argument is deprecated and produces a TypeScript error. But if your &lt;code&gt;tsconfig&lt;/code&gt; is not in strict mode (common in older projects), it compiles cleanly and falls back to legacy invalidation behavior instead of the new SWR-based system.&lt;/p&gt;

&lt;p&gt;Pages stop reflecting mutations. No error anywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&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="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;// SWR, recommended for most content&lt;/span&gt;
&lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expire&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="c1"&gt;// Immediate expiry for webhooks/payments&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;npx @next/codemod@canary upgrade latest&lt;/code&gt; during your migration, it handles this automatically. But check your &lt;code&gt;tsconfig&lt;/code&gt; strictness regardless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: Tag String Mismatch Across Files
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/data.ts -- written by developer A&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;getProducts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="nf"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product-list&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// Tag is 'product-list'&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// app/actions/products.ts -- written by developer B&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;createProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ProductData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT INTO products ...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[...])&lt;/span&gt;
  &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// Different string, invalidates nothing&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different strings. Zero errors. The product list never refreshes after a new product is created. Users see stale data until &lt;code&gt;cacheLife&lt;/code&gt; expires on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&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;// lib/tags.ts -- one source of truth&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;products&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product-list&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`product-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`user-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;

&lt;span class="c1"&gt;// Both files import from tags&lt;/span&gt;
&lt;span class="nf"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tag typos become TypeScript errors. The string mismatch bug becomes structurally impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: Server Action Mutation Where the Acting User Sees Stale Data
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateProductPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UPDATE products SET price = $1 WHERE id = $2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;newPrice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`product-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;revalidateTag&lt;/code&gt; with any named profile uses stale-while-revalidate. It marks the cache as stale. The next request still gets the cached version while fresh data loads in the background.&lt;/p&gt;

&lt;p&gt;For the admin who just clicked save, that means they navigate to the product page and see the old price. Looks like the save failed. Causes confusion and duplicate mutations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;updateTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateProductPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UPDATE products SET price = $1 WHERE id = $2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;newPrice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="nf"&gt;updateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`product-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;// Acting user sees change immediately&lt;/span&gt;
  &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`product-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// Everyone else gets SWR&lt;/span&gt;
  &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;// Product list also refreshes&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;updateTag&lt;/code&gt; expires the cache entry immediately. The next request waits for fresh data. The admin sees their change. Everyone else gets the fast SWR treatment.&lt;/p&gt;

&lt;p&gt;Constraint: &lt;code&gt;updateTag&lt;/code&gt; only works inside Server Actions. In Route Handlers, use &lt;code&gt;revalidateTag(tag, { expire: 0 })&lt;/code&gt; instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 5: &lt;code&gt;updateTag&lt;/code&gt; in a Route Handler
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/webhooks/stripe/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;updateTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&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;parseStripeWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price.updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;updateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// Throws at runtime&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&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 compiles. It deploys. On the first real webhook call from Stripe, it throws at runtime. &lt;code&gt;updateTag&lt;/code&gt; only works inside Server Actions. Calling it anywhere else throws.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;revalidateTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&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;parseStripeWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price.updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expire&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="c1"&gt;// Immediate expiry in Route Handlers&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&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;h2&gt;
  
  
  Bug 6: Short &lt;code&gt;cacheLife&lt;/code&gt; That Silently Affects PPR
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;LiveStockStatus&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="nf"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;seconds&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// Seems right for live stock data&lt;/span&gt;
  &lt;span class="nf"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`stock-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetchStockLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cacheLife('seconds')&lt;/code&gt;, &lt;code&gt;revalidate: 0&lt;/code&gt;, and &lt;code&gt;expire&lt;/code&gt; under 5 minutes are automatically excluded from the PPR static shell. They become dynamic holes that run at request time.&lt;/p&gt;

&lt;p&gt;One component with &lt;code&gt;cacheLife('seconds')&lt;/code&gt; can push parts of the page out of the static shell and turn them into request-time work. No warning. The page still works. It just becomes fully dynamic without any obvious signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix, if the data can tolerate a short delay:&lt;/strong&gt;&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="nf"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;minutes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// Now included in the static shell&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Fix, if it genuinely needs to be live:&lt;/strong&gt;&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;// Parent page&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;StockSkeleton&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LiveStockStatus&lt;/span&gt; &lt;span class="na"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Streams in after static shell */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second approach is the correct PPR pattern for truly dynamic data. The static shell renders instantly and the live data streams in after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 7: Runtime API Inside a Cached Scope
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;UserHeader&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookieStore&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;cookies&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// Throws at build time&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&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;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cookieStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cookies()&lt;/code&gt;, &lt;code&gt;headers()&lt;/code&gt;, and &lt;code&gt;draftMode()&lt;/code&gt; are runtime APIs. They read request-specific data. They cannot live inside a &lt;code&gt;'use cache'&lt;/code&gt; scope because cached output is stored and replayed across requests.&lt;/p&gt;

&lt;p&gt;This one at least throws at build time with "Uncached data was accessed outside of Suspense". But the error gives you no component name, no file path, and no useful stack trace. You get to play binary search across your codebase to find it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&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;// Read runtime values OUTSIDE the cached scope&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;UserHeader&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cookieStore&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;cookies&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cookieStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CachedUserProfile&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Pass the VALUE as a serializable prop to the cached component&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;CachedUserProfile&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="nf"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hours&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`user-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;userId&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GuestGreeting&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&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;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;userId&lt;/code&gt; is a string so it becomes part of the cache key automatically. Different users produce different cache entries without any manual key construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Common Thread
&lt;/h2&gt;

&lt;p&gt;None of these have good error messages. Five of the seven compile and deploy without complaint. The other two throw, but either without enough information to find the cause quickly or only after the first real production request.&lt;/p&gt;

&lt;p&gt;The pattern across all of them is the same: the new caching model requires explicit correctness. When you get something wrong, it does not always tell you.&lt;/p&gt;

&lt;p&gt;If you are in the middle of a Next.js 16 migration and want to catch these during development rather than in production, I ended up building a free dev-only debugger that logs cache misses, dynamic holes, missing tags, and deprecated invalidation calls directly in your terminal. Zero production cost, one &lt;code&gt;.tsx&lt;/code&gt; file: &lt;a href="https://shubhra.dev/snippets/nextjs-use-cache-debugger" rel="noopener noreferrer"&gt;Next.js cache debugger&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;And if you want these patterns enforced at the type level so the wrong call is a compile error rather than a runtime surprise, the production enforcement layer is &lt;a href="https://shubhra.dev/snippets/nextjs-cache-pro" rel="noopener noreferrer"&gt;Cache Pro Kit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Have you run into any of these? Or something even stranger? I'm curious what the distribution looks like across different kinds of projects.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>I built a free debugger because Next.js 16 'use cache' was completely invisible during development</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Tue, 19 May 2026 10:29:28 +0000</pubDate>
      <link>https://dev.to/shubhradev/i-built-a-free-debugger-because-nextjs-16-use-cache-was-completely-invisible-during-development-4a8</link>
      <guid>https://dev.to/shubhradev/i-built-a-free-debugger-because-nextjs-16-use-cache-was-completely-invisible-during-development-4a8</guid>
      <description>&lt;p&gt;I spent an afternoon debugging a component that kept re-fetching on every single request.&lt;/p&gt;

&lt;p&gt;It had &lt;code&gt;'use cache'&lt;/code&gt; right there in the code. I was confident it was working. It wasn't.&lt;/p&gt;

&lt;p&gt;The problem was placement. &lt;code&gt;'use cache'&lt;/code&gt; was on the wrapper function, not inside the actual data function. That one mistake makes Next.js ignore the directive entirely. No error, no warning, nothing in the terminal. Just a function running on every request when it should have been cached.&lt;/p&gt;

&lt;p&gt;Another time I wrote this in a Server Action during a Next.js 16 migration:&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="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It compiled. It deployed. Pages stopped reflecting mutations. Calling &lt;code&gt;revalidateTag&lt;/code&gt; without a second argument is a TypeScript error in Next.js 16, but the runtime fell back to legacy behaviour silently. I only caught it when users started reporting stale data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next.js 16's new caching model is genuinely great. But during development it is a complete black box.&lt;/strong&gt; You add the directive, you assume it works, and you only find out otherwise when something breaks in production.&lt;/p&gt;

&lt;p&gt;So I built a small dev-only toolkit to make it visible.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;1. Silent cache misses&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If a function runs more than once with identical arguments, you see this immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[cache-debug] ⚠  POSSIBLE CACHE MISS - RE-EXECUTION WITH SAME ARGS
  fn:   getProductById
  args: ["prod-123"]
  This function ran 2 times with identical args.
  If you expect caching: check 'use cache' is inside this function, not the wrapper.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That warning would have saved me that entire afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Dynamic holes from short cacheLife&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cacheLife('seconds')&lt;/code&gt; silently excludes a component from the PPR static shell. It becomes fully dynamic. No warning anywhere, just a slower page that you cannot explain.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[cache-debug] ⚡ DYNAMIC HOLE WARNING
  fn:       getLivePrice
  cacheLife 'seconds' is short-lived (&amp;lt; 5 minutes or revalidate: 0).
  Next.js 16 automatically EXCLUDES this from the PPR static shell.
  This function will run at request time, it is NOT prerendered.
  Fix: Use 'minutes' or longer if you want it in the static shell.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Missing cacheTag&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A cached function with no &lt;code&gt;cacheTag()&lt;/code&gt; can only expire by time. You cannot revalidate it on demand. Easy to miss when moving fast, painful to discover later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[cache-debug] 🏷  MISSING cacheTag WARNING
  fn:   getProductById
  No cacheTag() found. This function cannot be invalidated on demand.
  It will only expire when cacheLife runs out.
  Fix: Add cacheTag('your-tag') inside the function.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Deprecated revalidateTag&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In Next.js 16, &lt;code&gt;revalidateTag('tag')&lt;/code&gt; without a second argument is a TypeScript error. The &lt;code&gt;logInvalidation&lt;/code&gt; helper catches it before your CI does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[cache-debug] ✗  DEPRECATED revalidateTag - MISSING SECOND ARG
  tag:     products
  revalidateTag('products') without a profile is deprecated in Next.js 16.
  Fix: revalidateTag('products', 'max')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5. updateTag outside a Server Action&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Calling &lt;code&gt;updateTag&lt;/code&gt; outside a Server Action throws at runtime. The toolkit catches it at dev time before it reaches production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Repeated fetches&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;detectRepeatedFetch&lt;/code&gt; surfaces the same URL being hit multiple times in one render. Usually means a cache layer is missing entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Enable in &lt;code&gt;.env.local&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;CACHE_DEBUG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not add this to &lt;code&gt;.env.production&lt;/code&gt;. The &lt;code&gt;NODE_ENV&lt;/code&gt; guard already ensures it is off in production, but keeping env files clean is good practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Wrap your cached functions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;'use cache'&lt;/code&gt; directive must stay inside the original function. &lt;code&gt;withCacheDebug&lt;/code&gt; is a regular wrapper and cannot be a cache boundary. If you put &lt;code&gt;'use cache'&lt;/code&gt; on the wrapper, the instrumentation gets cached instead of the data function, which is exactly the mistake the POSSIBLE CACHE MISS warning is designed to catch.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cacheTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;withCacheDebug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/cache-debug&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&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;_getProductById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hours&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`product-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT * FROM products WHERE id = $1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&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;const&lt;/span&gt; &lt;span class="nx"&gt;getProductById&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withCacheDebug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_getProductById&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;getProductById&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hours&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;product-{id}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&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;getProductById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prod-123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero API change. The exported function works exactly the same everywhere you already call it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: Log invalidation calls in Server Actions&lt;/strong&gt;&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;updateTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;logInvalidation&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/cache-debug&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;updateProductPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UPDATE products SET price = $1 WHERE id = $2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;newPrice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="nf"&gt;logInvalidation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;updateTag&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`product-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;isServerAction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin price update&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;updateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`product-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;logInvalidation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;revalidateTag&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;isServerAction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin price update&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;products&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Process-scoped.&lt;/strong&gt; Execution maps reset on cold start. In serverless environments each invocation may be a fresh process, so you will only see re-execution data within the same warm instance. For local dev with a long-running server it works exactly as intended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best-effort concurrency.&lt;/strong&gt; Under concurrent rendering with identical args, both calls may log FIRST RUN rather than a miss. Detection does not affect correctness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cannot inspect Next.js internals.&lt;/strong&gt; The debugger counts executions to detect likely misses. It cannot read Next.js's internal cache store directly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these affect production because the tool is not present there.&lt;/p&gt;

&lt;h2&gt;
  
  
  At a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What it detects&lt;/th&gt;
&lt;th&gt;Without this tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FIRST RUN / CACHE MISS / NEW KEY&lt;/td&gt;
&lt;td&gt;Not visible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic hole from short cacheLife&lt;/td&gt;
&lt;td&gt;Not visible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing cacheTag&lt;/td&gt;
&lt;td&gt;Not visible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deprecated revalidateTag&lt;/td&gt;
&lt;td&gt;TypeScript error, easy to miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;updateTag outside Server Action&lt;/td&gt;
&lt;td&gt;Runtime throw&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repeated fetches in one render&lt;/td&gt;
&lt;td&gt;Not visible&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero external dependencies. TypeScript 5.0+ with strict mode. Next.js 16 only -- &lt;code&gt;updateTag&lt;/code&gt; does not exist in Next.js 15. Double-gated on &lt;code&gt;NODE_ENV === 'development'&lt;/code&gt; AND &lt;code&gt;CACHE_DEBUG=true&lt;/code&gt; so nothing ships to production. No overhead, no bundle impact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get it
&lt;/h2&gt;

&lt;p&gt;Free forever, one &lt;code&gt;.tsx&lt;/code&gt; file: &lt;a href="https://shubhra.dev/snippets/nextjs-use-cache-debugger" rel="noopener noreferrer"&gt;shubhra.dev/snippets/nextjs-use-cache-debugger&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want the production enforcement layer that pairs with this -- type-safe tag registry, &lt;code&gt;safeRevalidate&lt;/code&gt; that blocks the deprecated single-arg call at compile time, &lt;code&gt;serverActionInvalidate&lt;/code&gt; that enforces the correct invalidation order -- that is the &lt;a href="https://shubhra.dev/snippets/nextjs-cache-pro" rel="noopener noreferrer"&gt;Cache Pro Kit&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you are new to the Next.js 16 caching model and want to understand what &lt;code&gt;'use cache'&lt;/code&gt;, &lt;code&gt;cacheLife&lt;/code&gt;, and &lt;code&gt;cacheTag&lt;/code&gt; are actually doing before using this toolkit, the &lt;a href="https://shubhra.dev/tutorials/nextjs-16-cache-components" rel="noopener noreferrer"&gt;practical migration guide&lt;/a&gt; covers the full picture. There is also a &lt;a href="https://shubhra.dev/quiz/nextjs-16-cache-components" rel="noopener noreferrer"&gt;15-question quiz&lt;/a&gt; if you want to test your understanding.&lt;/p&gt;

&lt;p&gt;If you are in the middle of a Next.js 16 caching migration and something is behaving unexpectedly, this will make it visible. What has been the most frustrating or confusing part of the new caching model for you?&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>performance</category>
    </item>
    <item>
      <title>Lighthouse Said It Was Fast. My Users Kept Clicking the Same Button.</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Wed, 06 May 2026 09:46:33 +0000</pubDate>
      <link>https://dev.to/shubhradev/lighthouse-said-it-was-fast-my-users-kept-clicking-the-same-button-4lng</link>
      <guid>https://dev.to/shubhradev/lighthouse-said-it-was-fast-my-users-kept-clicking-the-same-button-4lng</guid>
      <description>&lt;h2&gt;
  
  
  The bug wasn’t performance. It was silence.
&lt;/h2&gt;

&lt;p&gt;I ran into this while building a Next.js dashboard.&lt;/p&gt;

&lt;p&gt;There was a button that triggered a report generation flow. Some client-side processing, then an API call.&lt;/p&gt;

&lt;p&gt;Lighthouse looked great. API response was around 80ms.&lt;/p&gt;

&lt;p&gt;But session recordings told a different story.&lt;/p&gt;

&lt;p&gt;Users were clicking the button 3 to 5 times in a row.&lt;/p&gt;

&lt;p&gt;No errors in logs. No failed requests. Everything technically worked.&lt;/p&gt;

&lt;p&gt;So I watched one of the recordings more carefully.&lt;/p&gt;

&lt;p&gt;The moment the user clicked, nothing changed on screen.&lt;/p&gt;

&lt;p&gt;No loading state.&lt;br&gt;
No disabled button.&lt;br&gt;
No visual feedback at all.&lt;/p&gt;

&lt;p&gt;The UI looked exactly the same before and after the click.&lt;/p&gt;

&lt;p&gt;That was the issue.&lt;/p&gt;
&lt;h2&gt;
  
  
  80ms response, zero perceived response
&lt;/h2&gt;

&lt;p&gt;The backend was fast.&lt;/p&gt;

&lt;p&gt;The interface just didn’t acknowledge the interaction.&lt;/p&gt;

&lt;p&gt;From the user’s point of view, the click didn’t go through.&lt;/p&gt;

&lt;p&gt;So they clicked again.&lt;/p&gt;

&lt;p&gt;And again.&lt;/p&gt;

&lt;p&gt;Not because they were impatient. Because the UI stayed silent.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where most of us focus (and where it falls short)
&lt;/h2&gt;

&lt;p&gt;We spend a lot of time optimizing load performance.&lt;/p&gt;

&lt;p&gt;FCP, LCP, bundle size, Lighthouse scores. All worth caring about.&lt;/p&gt;

&lt;p&gt;But most of the time a user spends on your app happens after the page has loaded.&lt;/p&gt;

&lt;p&gt;That’s where trust is decided. In the interactions.&lt;/p&gt;
&lt;h2&gt;
  
  
  The 200ms line
&lt;/h2&gt;

&lt;p&gt;There’s a threshold that shows up consistently in real usage.&lt;/p&gt;

&lt;p&gt;If the UI responds within roughly 100 to 200ms, it feels instant.&lt;/p&gt;

&lt;p&gt;Between 200 and 500ms, the delay becomes noticeable.&lt;/p&gt;

&lt;p&gt;Beyond that, people start questioning whether their action worked.&lt;/p&gt;

&lt;p&gt;A slow app is frustrating. An app that feels unresponsive gets abandoned.&lt;/p&gt;
&lt;h2&gt;
  
  
  What INP actually measures
&lt;/h2&gt;

&lt;p&gt;INP (Interaction to Next Paint) focuses on one thing:&lt;/p&gt;

&lt;p&gt;How long it takes for the user to &lt;em&gt;see&lt;/em&gt; a response after they interact.&lt;/p&gt;

&lt;p&gt;Not when your API returns.&lt;br&gt;
Not when your function finishes.&lt;/p&gt;

&lt;p&gt;When something visibly changes on the screen.&lt;/p&gt;

&lt;p&gt;That’s what closes the loop.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix was not “make it faster”
&lt;/h2&gt;

&lt;p&gt;The fix was: acknowledge the interaction immediately.&lt;/p&gt;

&lt;p&gt;Instead of doing all the work first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;button&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;syncWithServer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;setLoading&lt;/span&gt;&lt;span class="p"&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;Flip it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;button&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// immediate visual feedback&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// let the browser paint&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;processData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;syncWithServer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nf"&gt;setLoading&lt;/span&gt;&lt;span class="p"&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;If &lt;code&gt;scheduler.yield()&lt;/code&gt; is not available yet in your environment, a simple fallback works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;yieldToMain&lt;/span&gt; &lt;span class="o"&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;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&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;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same total work. Same total time.&lt;/p&gt;

&lt;p&gt;Completely different experience.&lt;/p&gt;

&lt;p&gt;Users stopped clicking multiple times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern you already use
&lt;/h2&gt;

&lt;p&gt;You’ve seen this in every modern app.&lt;/p&gt;

&lt;p&gt;You tap like. It updates instantly. The server confirms later.&lt;/p&gt;

&lt;p&gt;That’s optimistic UI.&lt;/p&gt;

&lt;p&gt;Here is the same idea in a simple form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleLike&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setLiked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;like&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setLiked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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;In React and Next.js setups, this maps cleanly to &lt;code&gt;useOptimistic&lt;/code&gt; and Server Actions.&lt;/p&gt;

&lt;p&gt;The idea stays the same:&lt;/p&gt;

&lt;p&gt;Show the result immediately. Reconcile in the background.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more than Lighthouse 100
&lt;/h2&gt;

&lt;p&gt;Lighthouse runs in controlled conditions.&lt;/p&gt;

&lt;p&gt;Your users don’t.&lt;/p&gt;

&lt;p&gt;They’re on slower devices, switching tabs, running background apps, dealing with network jitter.&lt;/p&gt;

&lt;p&gt;You can ship a perfect score and still have interactions that feel off.&lt;/p&gt;

&lt;p&gt;INP exposes that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to go deeper
&lt;/h2&gt;

&lt;p&gt;This bug was the entry point for me.&lt;/p&gt;

&lt;p&gt;I ended up breaking this down properly across INP, optimistic UI, task yielding, and how this fits into modern Next.js setups like Server Actions and streaming.&lt;/p&gt;

&lt;p&gt;I wrote it up in detail here:&lt;br&gt;
&lt;a href="https://shubhra.dev/tutorials/performance-first-ui-mastery-guide-2026" rel="noopener noreferrer"&gt;https://shubhra.dev/tutorials/performance-first-ui-mastery-guide-2026&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That one goes deeper into how to actually implement this in real apps. This post is just the moment where the problem becomes obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick way to test this
&lt;/h2&gt;

&lt;p&gt;While working through this, I built a small quiz to check if I actually understood the idea or was just agreeing with it.&lt;/p&gt;

&lt;p&gt;Here’s one of the questions:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A user clicks a button.&lt;br&gt;
The server responds in 80ms.&lt;br&gt;
They click four more times.&lt;/p&gt;

&lt;p&gt;What actually failed?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most people still go to backend performance first.&lt;/p&gt;

&lt;p&gt;If that was your instinct too, you’ll probably find the rest useful:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shubhra.dev/quiz/performance-first-ui" rel="noopener noreferrer"&gt;https://shubhra.dev/quiz/performance-first-ui&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It’s short. Focused on real interaction problems. No trick questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  One practical takeaway
&lt;/h2&gt;

&lt;p&gt;Next time you write a click handler, check the order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does something change on screen immediately?&lt;/li&gt;
&lt;li&gt;Or does all the work happen before the user sees anything?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That one decision is often the difference between a UI that feels fast and one that feels broken.&lt;/p&gt;

&lt;p&gt;If you’ve run into this in your own apps, I’m curious what caused it and how you approached it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>nextjs</category>
      <category>react</category>
    </item>
    <item>
      <title>Why Most React Infinite Scroll Hooks Fail in Production (and the One That Fixed It for Me)</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Thu, 30 Apr 2026 11:28:52 +0000</pubDate>
      <link>https://dev.to/shubhradev/why-most-react-infinite-scroll-hooks-fail-in-production-and-the-one-that-fixed-it-for-me-moa</link>
      <guid>https://dev.to/shubhradev/why-most-react-infinite-scroll-hooks-fail-in-production-and-the-one-that-fixed-it-for-me-moa</guid>
      <description>&lt;p&gt;I used to believe infinite scroll was one of the simplest features to implement.&lt;/p&gt;

&lt;p&gt;Fetch data → append to a list → load more on scroll.&lt;/p&gt;

&lt;p&gt;Easy, right?&lt;/p&gt;

&lt;p&gt;That’s exactly how most tutorials show it.&lt;/p&gt;

&lt;p&gt;And honestly… it works.&lt;/p&gt;

&lt;p&gt;Until it doesn’t.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug That Changed Everything
&lt;/h2&gt;

&lt;p&gt;One bug completely changed how I think about infinite scroll.&lt;/p&gt;

&lt;p&gt;A user changed a filter → the list reset → new data loaded.&lt;/p&gt;

&lt;p&gt;Everything looked correct.&lt;/p&gt;

&lt;p&gt;Then a couple of seconds later…&lt;/p&gt;

&lt;p&gt;the old results came back and overwrote the new ones.&lt;/p&gt;

&lt;p&gt;No errors. No warnings.&lt;/p&gt;

&lt;p&gt;Just silently broken UI.&lt;/p&gt;

&lt;p&gt;What actually happened?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A slow request from the previous state finished late and updated the UI with stale data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That was the moment I realized:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infinite scroll doesn’t break in demos.It breaks with real users, real timing, and real networks.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Things Start Falling Apart
&lt;/h2&gt;

&lt;p&gt;After that, I started noticing the same issues again and again:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stale requests overwriting fresh data after reset&lt;/li&gt;
&lt;li&gt;Retry logic fetching the wrong page&lt;/li&gt;
&lt;li&gt;IntersectionObserver continuing to fire after errors&lt;/li&gt;
&lt;li&gt;Duplicate requests in React Strict Mode&lt;/li&gt;
&lt;li&gt;Loading states that never resolve&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These problems rarely show up locally.&lt;/p&gt;

&lt;p&gt;But in production, they show up fast.&lt;/p&gt;

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

&lt;p&gt;Not another demo.&lt;/p&gt;

&lt;p&gt;Something I could trust in a real app.&lt;/p&gt;

&lt;p&gt;So I built a hook around one idea:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Control the async flow. Don’t just “load more data”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What This Hook Handles
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;✔ Cancels stale requests (no overwrite bugs)&lt;/li&gt;
&lt;li&gt;✔ Smart retry logic (correct page every time)&lt;/li&gt;
&lt;li&gt;✔ Proper observer cleanup (no leaks)&lt;/li&gt;
&lt;li&gt;✔ React 18+ Strict Mode safe&lt;/li&gt;
&lt;li&gt;✔ Clear initial vs pagination loading states&lt;/li&gt;
&lt;li&gt;✔ Manual &lt;code&gt;loadMore&lt;/code&gt;, &lt;code&gt;reset&lt;/code&gt;, and &lt;code&gt;retry&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;✔ Zero dependencies&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Basic Usage
&lt;/h2&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;isInitialLoading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;hasMore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;loadMoreRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;reset&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useInfiniteScroll&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;fetchFn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/posts?page=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;pageSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Sentinel element */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;loadMoreRef&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;retry&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Retry&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Real Use Case: Reset on Filter Change
&lt;/h2&gt;

&lt;p&gt;This is where most bugs happen.&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="nf"&gt;useEffect&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="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// cancels old request + reloads safely&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without proper handling, this is exactly where stale data bugs appear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AbortController Matters So Much
&lt;/h2&gt;

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

&lt;p&gt;old requests still resolved&lt;br&gt;
stale data overwrote the UI&lt;/p&gt;

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

&lt;p&gt;old requests get cancelled immediately&lt;br&gt;
they never reach your state&lt;/p&gt;

&lt;p&gt;That single change removes an entire class of bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Honest Note
&lt;/h2&gt;

&lt;p&gt;This hook handles most real-world issues.&lt;/p&gt;

&lt;p&gt;But for very large lists, you should still use virtualization&lt;br&gt;
(like react-window or tanstack/virtual).&lt;/p&gt;

&lt;p&gt;Infinite scroll solves loading.&lt;/p&gt;

&lt;p&gt;Virtualization solves rendering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;You can grab the full hook here:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://shubhra.dev/snippets/use-infinite-scroll" rel="noopener noreferrer"&gt;https://shubhra.dev/snippets/use-infinite-scroll&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;MIT licensed&lt;br&gt;
free to copy and modify&lt;br&gt;
built for real production cases&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thought
&lt;/h2&gt;

&lt;p&gt;I didn’t build this because infinite scroll is hard.&lt;/p&gt;

&lt;p&gt;I built it because the small edge cases are what break real products.&lt;/p&gt;

&lt;p&gt;Slow APIs. Fast users. Overlapping requests.&lt;/p&gt;

&lt;p&gt;That’s where things actually fall apart.&lt;/p&gt;

&lt;p&gt;Curious - what broke for you?&lt;/p&gt;

&lt;p&gt;Have you ever hit a weird infinite scroll bug in production?&lt;/p&gt;

&lt;p&gt;Something like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;duplicate data&lt;/li&gt;
&lt;li&gt;stale UI&lt;/li&gt;
&lt;li&gt;loading that never stops&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Would genuinely love to hear what you ran into.&lt;/p&gt;

</description>
      <category>react</category>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Turned My Blog Into a Full Developer Platform (Tutorials + Snippets + Quizzes + Tools)</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Tue, 28 Apr 2026 12:25:37 +0000</pubDate>
      <link>https://dev.to/shubhradev/i-turned-my-blog-into-a-full-developer-platform-tutorials-snippets-quizzes-tools-2jd7</link>
      <guid>https://dev.to/shubhradev/i-turned-my-blog-into-a-full-developer-platform-tutorials-snippets-quizzes-tools-2jd7</guid>
      <description>&lt;p&gt;I’ve been working on building a developer platform with tutorials, reusable code snippets, interactive quizzes, and practical tools. This post shares what changed and what I’m building now.&lt;/p&gt;

&lt;p&gt;For the past few months, I’ve been a little quiet here.&lt;/p&gt;

&lt;p&gt;If you’ve been active on Dev.to regularly, you probably noticed I wasn’t reading as many posts, commenting, or sharing like I used to. And honestly, I missed that.&lt;/p&gt;

&lt;p&gt;This community has always been one of my favorite places to learn and grow. Reading how others think, build, and solve problems has helped me a lot in my own journey.&lt;/p&gt;

&lt;p&gt;So stepping away from that wasn’t easy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Went Quiet
&lt;/h2&gt;

&lt;p&gt;I reached a point where I felt like just writing blog posts wasn’t enough for me anymore.&lt;/p&gt;

&lt;p&gt;Don’t get me wrong, I still love writing. But I kept thinking:&lt;/p&gt;

&lt;p&gt;What if I could make something more useful than just articles?&lt;/p&gt;

&lt;p&gt;Something that helps people not only read, but also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;try things quickly
&lt;/li&gt;
&lt;li&gt;test their understanding
&lt;/li&gt;
&lt;li&gt;and actually use code in real projects
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That idea stayed in my mind for a while.&lt;/p&gt;

&lt;p&gt;And instead of talking about it, I decided to just build it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I’ve Been Working On
&lt;/h2&gt;

&lt;p&gt;I took my simple blog and slowly turned it into something more complete.&lt;/p&gt;

&lt;p&gt;Now it’s not just posts. It includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tutorials that go step by step
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here’s how one of the tutorials looks:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxx4vawav1qutx2dshyzj.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxx4vawav1qutx2dshyzj.gif" alt="Next.js Tutorial" width="480" height="219"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Small, practical code snippets you can directly use
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A quick example of a reusable snippet in action:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwq4xhpkauqjqf1e1gi8q.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwq4xhpkauqjqf1e1gi8q.gif" alt="useInfiniteScroll Snippet" width="480" height="217"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Quizzes to test concepts in a quick way
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here’s a quick look at how the quiz works:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk6o61afb8qfbvf6sp3bd.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk6o61afb8qfbvf6sp3bd.gif" alt="Quiz Demo" width="560" height="253"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here’s a small taste of what the quizzes look like:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. What does the &lt;code&gt;===&lt;/code&gt; operator do?&lt;/strong&gt;  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Checks for value equality only
&lt;/li&gt;
&lt;li&gt;Checks for reference equality only
&lt;/li&gt;
&lt;li&gt;Checks for both value and type equality without coercion
&lt;/li&gt;
&lt;li&gt;Assigns a value
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;→ Correct answer: Checks for both value and type equality without coercion  &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. What is a closure in JavaScript?&lt;/strong&gt;  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A way to immediately end a function
&lt;/li&gt;
&lt;li&gt;A function that retains access to its outer scope
&lt;/li&gt;
&lt;li&gt;A syntax error
&lt;/li&gt;
&lt;li&gt;A loop
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;→ Correct answer: A function that retains access to its outer scope  &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. What will &lt;code&gt;typeof null&lt;/code&gt; return?&lt;/strong&gt;  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;'null'
&lt;/li&gt;
&lt;li&gt;'undefined'
&lt;/li&gt;
&lt;li&gt;'object'
&lt;/li&gt;
&lt;li&gt;'number'
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;→ Correct answer: 'object'  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A couple of products built from things I personally needed
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It’s still growing, still improving, and definitely not perfect.&lt;/p&gt;

&lt;p&gt;But it feels closer to what I actually wanted to create.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed for Me
&lt;/h2&gt;

&lt;p&gt;Earlier, I was mostly focused on sharing information.&lt;/p&gt;

&lt;p&gt;Now I’m trying to focus more on usefulness.&lt;/p&gt;

&lt;p&gt;Instead of just explaining things, I want to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;make it easier to apply them
&lt;/li&gt;
&lt;li&gt;keep things simple and practical
&lt;/li&gt;
&lt;li&gt;and help someone save time when they’re stuck
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even the smallest snippet can be helpful if it solves a real problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  I Missed This Community
&lt;/h2&gt;

&lt;p&gt;One thing I realized during this time is how much I value this space.&lt;/p&gt;

&lt;p&gt;Reading posts here, seeing different approaches, and learning from others is something I genuinely enjoy.&lt;/p&gt;

&lt;p&gt;I missed that part.&lt;/p&gt;

&lt;p&gt;So I’m coming back, not just to share, but also to read, learn, and engage again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You’ll See From Me Now
&lt;/h2&gt;

&lt;p&gt;I’ll start sharing more regularly again, but with a slightly different approach.&lt;/p&gt;

&lt;p&gt;You’ll see things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;small useful snippets
&lt;/li&gt;
&lt;li&gt;short explanations
&lt;/li&gt;
&lt;li&gt;occasional deep tutorials
&lt;/li&gt;
&lt;li&gt;and some interesting questions or quizzes
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing too heavy. Just practical and helpful.&lt;/p&gt;

&lt;h2&gt;
  
  
  If You Want to Check What I Built
&lt;/h2&gt;

&lt;p&gt;Here’s how everything comes together:&lt;/p&gt;

&lt;p&gt;If you’re curious, you can take a look here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://shubhra.dev/" rel="noopener noreferrer"&gt;https://shubhra.dev/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No pressure at all. Just sharing what I’ve been working on.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Small Thought
&lt;/h2&gt;

&lt;p&gt;Sometimes we feel like we need to keep posting consistently no matter what.&lt;/p&gt;

&lt;p&gt;But taking a step back to build something meaningful can be just as important.&lt;/p&gt;

&lt;p&gt;For me, this phase helped me rethink what I actually want to create and share.&lt;/p&gt;

&lt;p&gt;And I’m glad I took that time.&lt;/p&gt;

&lt;p&gt;I’m looking forward to reading your posts again and being more active here.&lt;/p&gt;

&lt;p&gt;If you’ve been working on something quietly too, I’d love to hear about it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>fullstack</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Performance First UI Taught Me More Than Any Framework Ever Did</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Mon, 12 Jan 2026 10:40:43 +0000</pubDate>
      <link>https://dev.to/shubhradev/performance-first-ui-taught-me-more-than-any-framework-ever-did-3c0d</link>
      <guid>https://dev.to/shubhradev/performance-first-ui-taught-me-more-than-any-framework-ever-did-3c0d</guid>
      <description>&lt;p&gt;The slowest app I ever worked on had a perfect performance score.&lt;br&gt;&lt;br&gt;
That sentence used to confuse me.&lt;br&gt;&lt;br&gt;
Everything was optimized. Bundles were trimmed. Images were compressed. Lighthouse smiled back at me like a proud teacher.  &lt;/p&gt;

&lt;p&gt;And yet, users kept behaving strangely.&lt;br&gt;&lt;br&gt;
They clicked twice.&lt;br&gt;&lt;br&gt;
They hesitated.&lt;br&gt;&lt;br&gt;
They abandoned flows halfway through.  &lt;/p&gt;

&lt;p&gt;At first, I blamed users. Then devices. Then networks.&lt;br&gt;&lt;br&gt;
Eventually, I blamed myself.&lt;br&gt;&lt;br&gt;
That was the moment I started understanding &lt;strong&gt;performance first UI&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed is not what developers think it is
&lt;/h2&gt;

&lt;p&gt;As developers, we grow up believing speed is a number.&lt;br&gt;&lt;br&gt;
Milliseconds.&lt;br&gt;&lt;br&gt;
Scores.&lt;br&gt;&lt;br&gt;
Graphs.  &lt;/p&gt;

&lt;p&gt;But users never see numbers. They feel moments.&lt;br&gt;&lt;br&gt;
The moment after a click.&lt;br&gt;&lt;br&gt;
The moment after a tap.&lt;br&gt;&lt;br&gt;
The moment when they expect acknowledgment.  &lt;/p&gt;

&lt;p&gt;If nothing happens in that moment, something breaks not technically, but emotionally.&lt;/p&gt;

&lt;h2&gt;
  
  
  The day I stopped trusting Lighthouse alone
&lt;/h2&gt;

&lt;p&gt;Lighthouse is useful. It taught an entire generation to care about performance.&lt;br&gt;&lt;br&gt;
But in 2026, relying on Lighthouse alone is like judging a car by how fast it accelerates on an empty track.  &lt;/p&gt;

&lt;p&gt;Real users drive in traffic.&lt;br&gt;&lt;br&gt;
They scroll while data loads.&lt;br&gt;&lt;br&gt;
They click while JavaScript executes.&lt;br&gt;&lt;br&gt;
They type while hydration finishes.  &lt;/p&gt;

&lt;p&gt;That’s where performance first UI lives, not at load time, but during interaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Interaction is the real contract with the user
&lt;/h2&gt;

&lt;p&gt;A page load is an introduction.&lt;br&gt;&lt;br&gt;
An interaction is a promise.  &lt;/p&gt;

&lt;p&gt;When a user clicks a button, they are not asking for data. They are asking for reassurance.&lt;br&gt;&lt;br&gt;
&lt;em&gt;Did you hear me?&lt;/em&gt;&lt;br&gt;&lt;br&gt;
&lt;em&gt;Are you working?&lt;/em&gt;&lt;br&gt;&lt;br&gt;
&lt;em&gt;Can I trust you?&lt;/em&gt;  &lt;/p&gt;

&lt;p&gt;If your interface stays visually silent, the promise is broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Interaction to Next Paint quietly changed everything
&lt;/h2&gt;

&lt;p&gt;Interaction to Next Paint does something rare in web metrics.&lt;br&gt;&lt;br&gt;
It measures reality.  &lt;/p&gt;

&lt;p&gt;Not when logic finishes.&lt;br&gt;&lt;br&gt;
Not when promises resolve.&lt;br&gt;&lt;br&gt;
But when pixels change.  &lt;/p&gt;

&lt;p&gt;This sounds obvious, but it reframes how you write UI code.&lt;br&gt;&lt;br&gt;
Because suddenly, doing work before paint feels dangerous.  &lt;/p&gt;

&lt;p&gt;If this shift in thinking around Interaction to Next Paint resonated with you, I’ve explored it in much more depth in my long-form guide &lt;em&gt;&lt;a href="https://blog.shubhra.dev/performance-first-ui-mastery-guide-2026/" rel="noopener noreferrer"&gt;Performance First UI Mastery: A Critical Guide for 2026 Developers&lt;/a&gt;&lt;/em&gt;, where I break down real-world examples, beginner-to-advanced concepts, and how this mindset plays out in modern Next.js apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden enemy of modern interfaces
&lt;/h2&gt;

&lt;p&gt;Most INP problems are not caused by slow servers.&lt;br&gt;&lt;br&gt;
They are caused by good intentions.  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Too much logic inside event handlers
&lt;/li&gt;
&lt;li&gt;Too many state updates
&lt;/li&gt;
&lt;li&gt;Too many components reacting at once
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything works. Everything is correct. Everything is just slightly late.&lt;br&gt;&lt;br&gt;
And that slight lateness compounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The small habit that fixed most of my issues
&lt;/h2&gt;

&lt;p&gt;The biggest improvement I ever made to interaction performance was embarrassingly simple.&lt;br&gt;&lt;br&gt;
I stopped doing real work before showing feedback.  &lt;/p&gt;

&lt;p&gt;Instead of:&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Click → compute → update UI&lt;/strong&gt;  &lt;/p&gt;

&lt;p&gt;I switched to:&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Click → update UI → compute&lt;/strong&gt;  &lt;/p&gt;

&lt;p&gt;Nothing fancy. No new libraries.&lt;br&gt;&lt;br&gt;
Just respect for the paint cycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why modern Next.js makes this mindset possible
&lt;/h2&gt;

&lt;p&gt;Next.js quietly evolved alongside performance first UI thinking.  &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Partial Prerendering lets interfaces appear ready before they are complete.
&lt;/li&gt;
&lt;li&gt;Streaming lets content arrive without blocking interaction.
&lt;/li&gt;
&lt;li&gt;Server Actions let the UI move first and confirm later.
&lt;/li&gt;
&lt;li&gt;Edge Middleware removes entire decision trees from the client.
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Used correctly, these tools keep the browser free to respond.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance is not about doing less, it’s about doing things later
&lt;/h2&gt;

&lt;p&gt;This was a hard lesson for me.&lt;br&gt;&lt;br&gt;
I used to chase minimalism. Fewer features. Smaller bundles.  &lt;/p&gt;

&lt;p&gt;But performance first UI taught me something better.&lt;br&gt;&lt;br&gt;
You can do a lot, just not all at once.  &lt;/p&gt;

&lt;p&gt;Defer what the user cannot see.&lt;br&gt;&lt;br&gt;
Delay what the user does not care about yet.&lt;br&gt;&lt;br&gt;
Focus on what the user just did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The business side developers rarely talk about
&lt;/h2&gt;

&lt;p&gt;Here’s an uncomfortable truth.&lt;br&gt;&lt;br&gt;
A sluggish interaction costs more than a slow page load.  &lt;/p&gt;

&lt;p&gt;Because interaction delay happens when the user is already invested.&lt;br&gt;&lt;br&gt;
They clicked.&lt;br&gt;&lt;br&gt;
They intended.&lt;br&gt;&lt;br&gt;
They were ready.  &lt;/p&gt;

&lt;p&gt;Losing them here hurts conversion far more than a slow landing page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance is becoming a human skill again
&lt;/h2&gt;

&lt;p&gt;In 2026, AI can generate components.&lt;br&gt;&lt;br&gt;
AI can refactor code.&lt;br&gt;&lt;br&gt;
AI can optimize assets.  &lt;/p&gt;

&lt;p&gt;But AI cannot feel hesitation.&lt;br&gt;&lt;br&gt;
It cannot sense when a transition feels awkward.&lt;br&gt;&lt;br&gt;
It cannot sense when feedback arrives too late.  &lt;/p&gt;

&lt;p&gt;That sensitivity is now the developer’s real value.&lt;/p&gt;

&lt;h2&gt;
  
  
  What performance first UI actually demands from us
&lt;/h2&gt;

&lt;p&gt;It demands patience.&lt;br&gt;&lt;br&gt;
It demands that we observe our own interfaces instead of trusting dashboards.&lt;br&gt;&lt;br&gt;
It demands that we click our own buttons slowly and ask:  &lt;/p&gt;

&lt;p&gt;&lt;em&gt;Does this feel respectful?&lt;/em&gt;&lt;br&gt;&lt;br&gt;
Not impressive.&lt;br&gt;&lt;br&gt;
Not clever.&lt;br&gt;&lt;br&gt;
Respectful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final thought
&lt;/h2&gt;

&lt;p&gt;Performance first UI is not a trend. It is a correction.&lt;br&gt;&lt;br&gt;
A return to building software that listens.  &lt;/p&gt;

&lt;p&gt;If your UI responds instantly, users forgive imperfections.&lt;br&gt;&lt;br&gt;
If it hesitates, they remember.  &lt;/p&gt;

&lt;p&gt;In the end, performance is not about speed.&lt;br&gt;&lt;br&gt;
It is about trust.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>webperf</category>
      <category>ux</category>
      <category>inp</category>
    </item>
    <item>
      <title>How CSS Animation Helped Me Build Interfaces That Feel Alive</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Mon, 22 Dec 2025 06:24:42 +0000</pubDate>
      <link>https://dev.to/shubhradev/how-css-animation-helped-me-build-interfaces-that-feel-alive-9pb</link>
      <guid>https://dev.to/shubhradev/how-css-animation-helped-me-build-interfaces-that-feel-alive-9pb</guid>
      <description>&lt;p&gt;I always believed great UI was about color, spacing, structure, and typography. I spent years improving those things. I obsessed over grids. I experimented with type scales. I rewrote layout systems again and again.&lt;br&gt;&lt;br&gt;
Yet something still felt missing.&lt;/p&gt;

&lt;p&gt;Every interface I created worked perfectly, but emotionally it felt silent. It behaved like a machine. Nothing moved unless a page refreshed. Nothing acknowledged the user. I did not realize how empty that silence was until I compared my projects with modern interfaces around the web.&lt;/p&gt;

&lt;p&gt;Some apps felt warm and alive, even though their layouts were simple. Their buttons breathed. Their cards shifted gently when hovered. Even status messages faded in and out like they had personality.&lt;/p&gt;

&lt;p&gt;I kept asking myself&lt;br&gt;&lt;br&gt;
How are they doing that?&lt;/p&gt;

&lt;p&gt;That question pushed me toward CSS Animation.&lt;br&gt;&lt;br&gt;
Not as a design trend&lt;br&gt;
but as a missing piece of expression.&lt;/p&gt;

&lt;h2&gt;
  
  
  My First Real Encounter With Motion
&lt;/h2&gt;

&lt;p&gt;My turning point arrived inside a dashboard project. Everything looked clean and minimal. The data tables worked. The navigation was solid. The code was tidy.&lt;br&gt;&lt;br&gt;
But the experience lacked a pulse.&lt;/p&gt;

&lt;p&gt;One evening while polishing the UI, I decided to animate only a single button. Not to impress anyone, but to understand how it would feel. I added a soft hover lift and a clearer color transition. Nothing dramatic.&lt;/p&gt;

&lt;p&gt;The moment I refreshed the browser&lt;br&gt;
the UI reacted like it suddenly woke up.&lt;br&gt;&lt;br&gt;
The button rose slightly.&lt;br&gt;&lt;br&gt;
The shadow deepened.&lt;br&gt;&lt;br&gt;
It felt like the interface acknowledged me.&lt;/p&gt;

&lt;p&gt;It was such a small change that the code barely filled a few lines, and yet the product felt more professional instantly.&lt;br&gt;&lt;br&gt;
That tiny success completely changed how I saw CSS Animation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Motion Matters To Users
&lt;/h2&gt;

&lt;p&gt;Modern users expect movement even if they cannot explain why.&lt;br&gt;&lt;br&gt;
Motion tells the brain something is happening.&lt;br&gt;&lt;br&gt;
It guides attention with subtle cues.&lt;br&gt;&lt;br&gt;
It communicates structure faster than text.&lt;/p&gt;

&lt;p&gt;When a button lifts&lt;br&gt;
you know it is clickable.&lt;br&gt;&lt;br&gt;
When content fades gently&lt;br&gt;
you understand that something changed without reading anything.  &lt;/p&gt;

&lt;p&gt;Motion replaces confusion with instinct.&lt;/p&gt;

&lt;p&gt;Once I understood that, I saw animation everywhere:&lt;br&gt;&lt;br&gt;
apps, websites, mobile UI, operating systems, ecommerce stores.  &lt;/p&gt;

&lt;p&gt;Motion has become the language of modern interaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  CSS Animation Is Not Just Decoration
&lt;/h2&gt;

&lt;p&gt;Before learning it deeply, I believed animation was fancy.&lt;br&gt;&lt;br&gt;
I assumed it slowed pages down.&lt;br&gt;&lt;br&gt;
I thought it was extra work for no real benefit.&lt;br&gt;&lt;br&gt;
All of that was wrong.&lt;/p&gt;

&lt;p&gt;CSS Animation can improve usability in extremely practical ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Guiding users to the next step
&lt;/li&gt;
&lt;li&gt;Confirming they clicked something
&lt;/li&gt;
&lt;li&gt;Building visual rhythm
&lt;/li&gt;
&lt;li&gt;Reducing visual shock
&lt;/li&gt;
&lt;li&gt;Improving brand identity
&lt;/li&gt;
&lt;li&gt;Creating focus without clutter
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Motion is not frosting on top of UI. It is part of UI itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mindset Shift That Helped Me Learn Faster
&lt;/h2&gt;

&lt;p&gt;The biggest challenge was learning how to think about motion.&lt;/p&gt;

&lt;p&gt;I stopped asking &lt;em&gt;How do I make this look cool?&lt;/em&gt;&lt;br&gt;&lt;br&gt;
and started asking &lt;em&gt;What should the user feel here?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When they open a page, maybe they should feel welcomed.&lt;br&gt;&lt;br&gt;
When they press save, maybe they should feel confirmed.&lt;br&gt;&lt;br&gt;
When they hover over content, maybe they should feel engaged.  &lt;/p&gt;

&lt;p&gt;That mindset turned animation into storytelling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why CSS Animation Works So Well With Modern UI
&lt;/h2&gt;

&lt;p&gt;CSS Animation is not heavy, nor complicated, nor dependent on JavaScript.&lt;br&gt;&lt;br&gt;
It is built to run smoothly if you animate the right properties.&lt;/p&gt;

&lt;p&gt;Transforms and opacity create GPU-powered motion that feels fluid even on average devices.  &lt;/p&gt;

&lt;p&gt;The more I practiced, the more confident I became because I saw that animation can stay clean and fast when done with purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Most Surprising Part
&lt;/h2&gt;

&lt;p&gt;Clients noticed the improvements immediately.&lt;br&gt;&lt;br&gt;
They could not describe what changed in technical language,&lt;br&gt;&lt;br&gt;
but they kept saying the same thing -&lt;br&gt;
"This feels better.",&lt;/p&gt;

&lt;p&gt;Not &lt;em&gt;This looks better&lt;/em&gt;, but &lt;em&gt;This feels better.&lt;/em&gt;&lt;br&gt;
That sentence taught me something powerful:&lt;br&gt;&lt;br&gt;
Users care about feeling, not code.&lt;/p&gt;

&lt;h2&gt;
  
  
  A New Way Of Seeing My Projects
&lt;/h2&gt;

&lt;p&gt;Today I build interfaces very differently.&lt;br&gt;&lt;br&gt;
I do not start by applying motion everywhere.&lt;br&gt;&lt;br&gt;
I begin by searching for moments where the UI speaks to users.&lt;br&gt;
Moments that deserve attention, and moments that need softness.  &lt;/p&gt;

&lt;p&gt;Sometimes it is a card hover.&lt;br&gt;&lt;br&gt;
Sometimes it is a hero title reveal.&lt;br&gt;&lt;br&gt;
Sometimes it is a form success message.  &lt;/p&gt;

&lt;p&gt;Motion is now part of my design decisions, not an afterthought.&lt;/p&gt;

&lt;h2&gt;
  
  
  Want To Learn How I Apply CSS Animation To Real UI
&lt;/h2&gt;

&lt;p&gt;I recently wrote a deep story and breakdown on my blog showing how CSS Animation reshaped my understanding of UI feeling and movement.&lt;br&gt;&lt;br&gt;
You can read it here:&lt;br&gt;&lt;br&gt;
👉 &lt;a href="https://blog.shubhra.dev/css-animation-level-up-ui" rel="noopener noreferrer"&gt;https://blog.shubhra.dev/css-animation-level-up-ui&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you are a developer who wants your UI to feel more human, this will help you see animation differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thought
&lt;/h2&gt;

&lt;p&gt;The moment you experience your first animated interaction that feels natural, not flashy, you understand why motion matters.&lt;br&gt;&lt;br&gt;
CSS Animation is not only a visual tool, it is emotional design.&lt;br&gt;&lt;br&gt;
And once you learn it, you never see UI the same way again.&lt;/p&gt;

</description>
      <category>css</category>
      <category>animation</category>
      <category>webdev</category>
      <category>frontend</category>
    </item>
    <item>
      <title>The CSS Positioning Chaos: Why Your Elements Run Away (And How I Finally Mastered the 5 Keys)</title>
      <dc:creator>Shubhra Pokhariya</dc:creator>
      <pubDate>Fri, 28 Nov 2025 07:54:55 +0000</pubDate>
      <link>https://dev.to/shubhradev/the-css-positioning-chaos-why-your-elements-run-away-and-how-i-finally-mastered-the-5-keys-4apb</link>
      <guid>https://dev.to/shubhradev/the-css-positioning-chaos-why-your-elements-run-away-and-how-i-finally-mastered-the-5-keys-4apb</guid>
      <description>&lt;p&gt;There is a special kind of chaos that only frontend developers understand.&lt;br&gt;&lt;br&gt;
The kind where your layout looks perfect one second and then suddenly one button escapes to the top corner of the screen like it has its own dreams and ambitions.&lt;/p&gt;

&lt;p&gt;That chaos usually has a name: &lt;strong&gt;CSS Positioning&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;
Every developer goes through this phase.&lt;br&gt;&lt;br&gt;
Every beginner gets confused.  &lt;/p&gt;

&lt;p&gt;And honestly, even after years of working with CSS, I still smile whenever someone asks me:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“Why does my element move when I scroll?”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Because that question reminds me of my own journey, a journey full of tiny mistakes, confused evenings, and small victories that made CSS feel less like a puzzle and more like a friend.&lt;/p&gt;

&lt;p&gt;Recently, I wrote a full blog post on my main site where I broke down CSS Positioning in the simplest, most practical way I could.&lt;br&gt;&lt;br&gt;
Today I wanted to share a little behind the scenes of that post and also explain why CSS Positioning deserves more love than it gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Phase Where CSS Positioning Made Me Doubt My Career Choices
&lt;/h2&gt;

&lt;p&gt;Let me tell you something honestly.&lt;/p&gt;

&lt;p&gt;In my early days of coding, nothing confused me more than &lt;strong&gt;absolute&lt;/strong&gt; and &lt;strong&gt;relative&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;
I remember placing a badge inside a hero section and the moment I refreshed the page, it flew somewhere completely unrelated.&lt;br&gt;&lt;br&gt;
I thought I broke the browser. I even restarted my laptop once thinking something was wrong with VS Code.&lt;/p&gt;

&lt;p&gt;Little did I know it was just CSS Positioning doing its usual drama.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;static&lt;/code&gt; was too simple
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;relative&lt;/code&gt; felt too subtle
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;absolute&lt;/code&gt; felt too free
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fixed&lt;/code&gt; felt too stubborn
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sticky&lt;/code&gt; felt like magic
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And because I did not understand their personalities, I kept fighting them instead of letting them help me.&lt;/p&gt;

&lt;p&gt;It took me weeks to finally understand that CSS Positioning is not just about moving elements around.&lt;br&gt;&lt;br&gt;
It is about understanding how the browser thinks.&lt;br&gt;&lt;br&gt;
How it calculates boundaries.&lt;br&gt;&lt;br&gt;
How it decides the &lt;strong&gt;containing block&lt;/strong&gt;.&lt;br&gt;&lt;br&gt;
How each element follows a very logical set of rules.&lt;/p&gt;

&lt;p&gt;Once I understood this, the fear disappeared.&lt;br&gt;&lt;br&gt;
And CSS started feeling like a superpower.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Beauty Behind Understanding CSS Positioning
&lt;/h2&gt;

&lt;p&gt;When you truly understand CSS Positioning, you begin to see your layout differently.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Static&lt;/strong&gt; becomes your calm baseline.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relative&lt;/strong&gt; becomes your gentle adjustment tool.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Absolute&lt;/strong&gt; becomes the creative freedom you use when you want elements to float exactly where you want.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixed&lt;/strong&gt; becomes your loyal companion for floating buttons and sticky helpers.
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sticky&lt;/strong&gt; becomes the modern magic that makes long pages feel smoother.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The moment you master these five:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;your designs stop breaking
&lt;/li&gt;
&lt;li&gt;your elements stop running away
&lt;/li&gt;
&lt;li&gt;and your confidence grows instantly
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is why CSS Positioning is not just a “topic” — it is a core foundation that every modern frontend layout quietly depends on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Best Way To Learn CSS Positioning Is Not Memorizing It
&lt;/h2&gt;

&lt;p&gt;One of the main things I shared in my WordPress post is something simple but important:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CSS Positioning is not learned through memorization.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
It is learned through &lt;strong&gt;experience&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;You learn it by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;building small boxes
&lt;/li&gt;
&lt;li&gt;adding a badge
&lt;/li&gt;
&lt;li&gt;scrolling a page
&lt;/li&gt;
&lt;li&gt;trying &lt;code&gt;absolute&lt;/code&gt; without &lt;code&gt;relative&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;trying &lt;code&gt;sticky&lt;/code&gt; inside a tiny container
&lt;/li&gt;
&lt;li&gt;placing a button with &lt;code&gt;fixed&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;and observing every single behavior
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This hands‑on curiosity is what transforms CSS Positioning from confusing to intuitive.&lt;br&gt;&lt;br&gt;
And once it clicks, you will wonder how you ever struggled with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Topic Deserves More Attention
&lt;/h2&gt;

&lt;p&gt;We often jump directly into Flexbox, Grid, animations, Tailwind, frameworks, and component libraries.&lt;br&gt;&lt;br&gt;
But behind all of them, positioning still plays a silent role.&lt;/p&gt;

&lt;p&gt;The moment something goes wrong,&lt;br&gt;&lt;br&gt;
the moment a layout shifts,&lt;br&gt;&lt;br&gt;
the moment an element overlaps,&lt;br&gt;&lt;br&gt;
the moment a menu disappears,&lt;br&gt;&lt;br&gt;
we all return to the same root question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“Where is this element positioned, and relative to what?”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Understanding CSS Positioning early saves so much frustration later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;CSS Positioning is not scary.&lt;br&gt;&lt;br&gt;
It is simply misunderstood.&lt;/p&gt;

&lt;p&gt;And once you understand how these five values behave, you will start designing with more confidence, clarity, and creativity.&lt;/p&gt;

&lt;p&gt;Whether you are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a beginner trying to place a small badge
&lt;/li&gt;
&lt;li&gt;a student building a landing page
&lt;/li&gt;
&lt;li&gt;or someone revisiting fundamentals after years
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CSS Positioning is one of those topics that rewards you every time you give it attention.&lt;/p&gt;

&lt;p&gt;If you want the full detailed breakdown, the real examples, and the step‑by‑step mental model, check out my complete WordPress guide:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://blog.shubhra.dev/css-positioning-explained-5-keys/" rel="noopener noreferrer"&gt;CSS Positioning: The 5 Keys to Master Static, Relative, Absolute, Fixed, Sticky&lt;/a&gt;&lt;/p&gt;

</description>
      <category>css</category>
      <category>webdev</category>
      <category>beginners</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
