<?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: Waseem Anjum</title>
    <description>The latest articles on DEV Community by Waseem Anjum (@fabwaseem).</description>
    <link>https://dev.to/fabwaseem</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%2F1255115%2F67bc77d7-a192-4c19-9547-69041c151ed0.png</url>
      <title>DEV Community: Waseem Anjum</title>
      <link>https://dev.to/fabwaseem</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fabwaseem"/>
    <language>en</language>
    <item>
      <title>React View Transitions in Next.js 16.4: ViewTransition, addTransitionType and Tested Examples</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Sat, 24 Oct 2026 22:05:26 +0000</pubDate>
      <link>https://dev.to/fabwaseem/react-view-transitions-in-nextjs-164-viewtransition-addtransitiontype-and-tested-examples-41ln</link>
      <guid>https://dev.to/fabwaseem/react-view-transitions-in-nextjs-164-viewtransition-addtransitiontype-and-tested-examples-41ln</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/react-view-transitions" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;React View Transitions animate UI changes with the browser's View Transition API, declaratively. You wrap elements in &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt;, and React animates them when they enter, exit, update or move between pages. They became stable in &lt;a href="https://react.dev/blog/2026/09/09/react-19-3" rel="noopener noreferrer"&gt;React 19.3&lt;/a&gt; on September 9, 2026, and Next.js 16.4 ships React 19.3, so they work in the App Router with no configuration.&lt;/p&gt;

&lt;p&gt;I built a small Next.js 16.4.2 app with the four main patterns: a shared-element morph, a Suspense reveal, directional slides and a tab animation with &lt;code&gt;addTransitionType&lt;/code&gt;. Then I recorded every transition in headless Chrome 155 to see what actually fires. Most of it behaves as documented. Browser back and forward do not, so that gets its own section below.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are React View Transitions?
&lt;/h2&gt;

&lt;p&gt;React View Transitions are the &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; component and the &lt;code&gt;addTransitionType&lt;/code&gt; function, stable since React 19.3. They hand animations to the browser's &lt;code&gt;document.startViewTransition()&lt;/code&gt; from the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API" rel="noopener noreferrer"&gt;View Transition API&lt;/a&gt;: the browser snapshots the old and new UI and animates between them, while React decides which elements take part and which CSS class each animation uses.&lt;/p&gt;

&lt;p&gt;React picks the animation from how the tree changed. From the React 19.3 announcement:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;When it runs&lt;/th&gt;
&lt;th&gt;Prop&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;enter&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; is added&lt;/td&gt;
&lt;td&gt;&lt;code&gt;enter&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;exit&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; is removed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;exit&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;update&lt;/td&gt;
&lt;td&gt;Its children change style or content&lt;/td&gt;
&lt;td&gt;&lt;code&gt;update&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;share&lt;/td&gt;
&lt;td&gt;A named &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; is removed in one place and added in another&lt;/td&gt;
&lt;td&gt;&lt;code&gt;share&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;(fallback)&lt;/td&gt;
&lt;td&gt;Any trigger without its own prop&lt;/td&gt;
&lt;td&gt;&lt;code&gt;default&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each prop takes &lt;code&gt;"auto"&lt;/code&gt; (the browser's crossfade), &lt;code&gt;"none"&lt;/code&gt;, a CSS class name you style, or an object that maps transition types to classes.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq79otzz976lpcaook3ve.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq79otzz976lpcaook3ve.jpg" alt="Four React View Transition patterns in Next.js: shared element morph with a matching name, Suspense reveal with exit and enter, directional slides with transitionTypes, and addTransitionType for tabs" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What triggers a view transition in React?
&lt;/h2&gt;

&lt;p&gt;Only Transitions do. React animates updates inside &lt;code&gt;startTransition&lt;/code&gt;, a &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt; boundary revealing its content, and updates from &lt;code&gt;useDeferredValue&lt;/code&gt;. A plain &lt;code&gt;setState&lt;/code&gt; does not animate, because React treats it as urgent. In Next.js App Router, every &lt;code&gt;&amp;lt;Link&amp;gt;&lt;/code&gt; and &lt;code&gt;router.push()&lt;/code&gt; navigation is a Transition, so &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; animates route changes automatically.&lt;/p&gt;

&lt;p&gt;This is the mistake behind most "my view transition doesn't run" reports. If you call &lt;code&gt;setIndex(2)&lt;/code&gt; directly in a click handler, nothing animates. Wrap it in &lt;code&gt;startTransition(() =&amp;gt; setIndex(2))&lt;/code&gt; and it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do View Transitions need a flag in Next.js 16.4?
&lt;/h2&gt;

&lt;p&gt;No. The &lt;a href="https://nextjs.org/docs/app/guides/view-transitions" rel="noopener noreferrer"&gt;Next.js view transitions guide&lt;/a&gt; says they "work in the App Router with no configuration." The &lt;code&gt;experimental.viewTransition&lt;/code&gt; option that older versions needed is gone: I searched Next.js 16.4.2's config schema and it has no &lt;code&gt;viewTransition&lt;/code&gt; key. You need React 19.3, which &lt;code&gt;next@16.4&lt;/code&gt; installs.&lt;/p&gt;

&lt;p&gt;Requirements and support on October 17, 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;React&lt;/td&gt;
&lt;td&gt;19.3.0 or newer (19.3.1 is &lt;code&gt;latest&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;16.4 (ships React 19.3); App Router&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import&lt;/td&gt;
&lt;td&gt;&lt;code&gt;import { ViewTransition, addTransitionType } from "react"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browsers&lt;/td&gt;
&lt;td&gt;Chromium 125+, recent Safari and Firefox (per Next.js docs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unsupported browsers&lt;/td&gt;
&lt;td&gt;The app works; changes happen without animation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platforms&lt;/td&gt;
&lt;td&gt;DOM only; React Native support is in progress&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Next.js notes that "some animations may behave differently in Safari." Test there before you ship anything elaborate.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I add view transitions to a Next.js app?
&lt;/h2&gt;

&lt;p&gt;Import &lt;code&gt;ViewTransition&lt;/code&gt; from &lt;code&gt;react&lt;/code&gt;, wrap the element you want to animate, and add CSS for the class names you pass. For route animations, give matching elements the same &lt;code&gt;name&lt;/code&gt; on both pages, tag links with &lt;code&gt;transitionTypes&lt;/code&gt;, and wrap each &lt;code&gt;page.tsx&lt;/code&gt; (not the layout) in a &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; that maps those types to animations.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwzp5p61lbcva32ycjfom.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwzp5p61lbcva32ycjfom.jpg" alt="How a Next.js navigation becomes a React view transition: Link with transitionTypes starts a Transition, React matches named ViewTransition pairs and enter/exit props, the browser snapshots old and new states and runs CSS on view-transition pseudo-elements" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My demo is a grid of four cards. Each card opens a detail page with a large hero, details that stream in from the server, and a tabs page. It runs Next.js 16.4.2, React 19.3.1 and &lt;code&gt;cacheComponents: true&lt;/code&gt; (the default for new 16.4 apps), so each detail page is a partial prerender: a static shell plus a dynamic hole. The patterns follow the official &lt;a href="https://github.com/nextjs/react-view-transitions-demo" rel="noopener noreferrer"&gt;Next.js view transitions demo&lt;/a&gt;, trimmed down so each one is easy to see.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Morph a shared element between pages
&lt;/h3&gt;

&lt;p&gt;Give the thumbnail and the hero the same &lt;code&gt;name&lt;/code&gt;. React pairs them across the navigation and the browser animates size and position:&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/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;ViewTransition&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;react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Link&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/link&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;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="nc"&gt;Link&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="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`/item/&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="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="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;transitionTypes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;nav-forward&lt;/span&gt;&lt;span class="dl"&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;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ViewTransition&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`card-&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="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="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;share&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"morph"&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"none"&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="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"card"&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;background&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="nx"&gt;color&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;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;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;ViewTransition&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/item/[id]/page.tsx&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ViewTransition&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`card-&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="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="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;share&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"morph"&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"none"&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="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"hero"&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;background&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="nx"&gt;color&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;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;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;ViewTransition&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;&lt;code&gt;default="none"&lt;/code&gt; stops the named element from crossfading during unrelated transitions. Next.js warns that with &lt;code&gt;default="none"&lt;/code&gt; you must keep the explicit &lt;code&gt;share&lt;/code&gt;, or "the pair silently stops morphing." Names must be unique on a page, so build them from an ID.&lt;/p&gt;

&lt;p&gt;The morph needs both elements in the same commit. Next.js says this happens with prefetched pages; if the destination suspends first, "no pair forms." In my app the hero sits in the static shell, which &lt;code&gt;&amp;lt;Link&amp;gt;&lt;/code&gt; prefetches, so it morphed on every click.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Animate a Suspense reveal
&lt;/h3&gt;

&lt;p&gt;Wrap the fallback in an exit animation and the content in an enter animation:&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/item/[id]/page.tsx&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;ViewTransition&lt;/span&gt; &lt;span class="na"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"slide-down"&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"none"&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="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"skeleton"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Loading details…&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;ViewTransition&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;ViewTransition&lt;/span&gt; &lt;span class="na"&gt;enter&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"slide-up"&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"none"&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;Details&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;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="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ViewTransition&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;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;&lt;code&gt;Details&lt;/code&gt; calls &lt;code&gt;await connection()&lt;/code&gt;, so it renders at request time. When it streamed in, React started a second, separate view transition about 800 ms after the navigation one, which matches the delay I put in the data function.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Slide forward and back
&lt;/h3&gt;

&lt;p&gt;Tag links with &lt;code&gt;transitionTypes&lt;/code&gt;, then map the types to classes in a wrapper you put in every page:&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;// components/directional.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;ViewTransition&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;react&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Directional&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&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="nc"&gt;ViewTransition&lt;/span&gt;
      &lt;span class="na"&gt;enter&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;nav-forward&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;nav-forward&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;nav-back&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;nav-back&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;nav-forward&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;nav-forward&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;nav-back&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;nav-back&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"none"&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;children&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;ViewTransition&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://www.fabwaseem.com/"&lt;/span&gt; &lt;span class="na"&gt;transitionTypes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;nav-back&lt;/span&gt;&lt;span class="dl"&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;gt;&lt;/span&gt;← All cards&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use it in each &lt;code&gt;page.tsx&lt;/code&gt;. Layouts persist across navigations, so enter and exit never fire on a wrapper placed there. &lt;code&gt;useRouter().push()&lt;/code&gt; and &lt;code&gt;replace()&lt;/code&gt; also accept &lt;code&gt;transitionTypes&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Pick the animation with addTransitionType
&lt;/h3&gt;

&lt;p&gt;Outside navigation, call &lt;code&gt;addTransitionType&lt;/code&gt; inside &lt;code&gt;startTransition&lt;/code&gt; to tell React why the update happened. The same &lt;code&gt;setIndex&lt;/code&gt; slides left or right depending on direction:&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 client&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;ViewTransition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;addTransitionType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Tabs&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;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIndex&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;go&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&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="nf"&gt;startTransition&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;addTransitionType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tab-next&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;tab-prev&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;setIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&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="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;tabs&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;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&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;t&lt;/span&gt;&lt;span class="si"&gt;}&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="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;go&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&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;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;t&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;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="nc"&gt;ViewTransition&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;index&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;enter&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;tab-next&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;from-right&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;tab-prev&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;from-left&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&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;tab-next&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;to-left&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;tab-prev&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;to-right&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"none"&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="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"panel"&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;tabs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; panel&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;ViewTransition&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&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;&lt;code&gt;key={index}&lt;/code&gt; makes React treat each panel as a new element, so it runs exit on the old one and enter on the new one. React also adds each type to the browser's view transition, so CSS can match it with &lt;code&gt;:active-view-transition-type(tab-next)&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The CSS
&lt;/h3&gt;

&lt;p&gt;The class names become selectors on the browser's view transition pseudo-elements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;::view-transition-group&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.morph&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation-duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;400ms&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nd"&gt;::view-transition-old&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.nav-forward&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--slide-offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-60px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;150ms&lt;/span&gt; &lt;span class="nb"&gt;ease-in&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt; &lt;span class="n"&gt;fade&lt;/span&gt; &lt;span class="nb"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;400ms&lt;/span&gt; &lt;span class="nb"&gt;ease-in-out&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt; &lt;span class="n"&gt;slide&lt;/span&gt; &lt;span class="nb"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nd"&gt;::view-transition-new&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.nav-forward&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--slide-offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;210ms&lt;/span&gt; &lt;span class="nb"&gt;ease-out&lt;/span&gt; &lt;span class="m"&gt;150ms&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt; &lt;span class="n"&gt;fade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;400ms&lt;/span&gt; &lt;span class="nb"&gt;ease-in-out&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt; &lt;span class="n"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nd"&gt;::view-transition-old&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.nav-back&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--slide-offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;150ms&lt;/span&gt; &lt;span class="nb"&gt;ease-in&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt; &lt;span class="n"&gt;fade&lt;/span&gt; &lt;span class="nb"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;400ms&lt;/span&gt; &lt;span class="nb"&gt;ease-in-out&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt; &lt;span class="n"&gt;slide&lt;/span&gt; &lt;span class="nb"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nd"&gt;::view-transition-new&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.nav-back&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--slide-offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;-60px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;210ms&lt;/span&gt; &lt;span class="nb"&gt;ease-out&lt;/span&gt; &lt;span class="m"&gt;150ms&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt; &lt;span class="n"&gt;fade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;400ms&lt;/span&gt; &lt;span class="nb"&gt;ease-in-out&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt; &lt;span class="n"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* Keep the header still and clicks working */&lt;/span&gt;
&lt;span class="nc"&gt;.site-header&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;view-transition-name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;site-header&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nd"&gt;::view-transition-group&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;site-header&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;z-index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nd"&gt;::view-transition-old&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;site-header&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nd"&gt;::view-transition-new&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;site-header&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nd"&gt;::view-transition&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;pointer-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;fade&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;slide&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="l"&gt;--slide-offset&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nt"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&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;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nd"&gt;::view-transition-old&lt;/span&gt;&lt;span class="o"&gt;(*),&lt;/span&gt; &lt;span class="nd"&gt;::view-transition-new&lt;/span&gt;&lt;span class="o"&gt;(*),&lt;/span&gt; &lt;span class="nd"&gt;::view-transition-group&lt;/span&gt;&lt;span class="o"&gt;(*)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;animation-duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0s&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;animation-delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0s&lt;/span&gt; &lt;span class="cp"&gt;!important&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 &lt;code&gt;pointer-events: none&lt;/code&gt; rule matters: while a transition runs, the &lt;code&gt;::view-transition&lt;/code&gt; overlay otherwise swallows clicks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did my test show?
&lt;/h2&gt;

&lt;p&gt;Every in-app pattern worked as documented. A forward click produced one transition typed &lt;code&gt;nav-forward&lt;/code&gt; with the &lt;code&gt;card-2&lt;/code&gt; pair morphing, then a second untyped transition when the streamed details arrived. Tabs got &lt;code&gt;tab-next&lt;/code&gt; and &lt;code&gt;tab-prev&lt;/code&gt;. Reduced motion kept the transitions but finished them in under 50 ms. Browser back and forward produced no view transition.&lt;/p&gt;

&lt;p&gt;I wrapped &lt;code&gt;document.startViewTransition&lt;/code&gt; in a Puppeteer script and logged each call's types, the pseudo-elements it animated, and how long it took to finish:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;View transitions started&lt;/th&gt;
&lt;th&gt;Types&lt;/th&gt;
&lt;th&gt;Finished in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Click card (grid to detail)&lt;/td&gt;
&lt;td&gt;2: navigation, then Suspense reveal&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;nav-forward&lt;/code&gt;, then none&lt;/td&gt;
&lt;td&gt;431 ms, 427 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Click "← All cards"&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;nav-back&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;433 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tab 0 to 2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tab-next&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;350 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tab 2 to 1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tab-prev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;334 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser back button&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser forward button&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Card click with &lt;code&gt;prefers-reduced-motion: reduce&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;nav-forward&lt;/code&gt;, then none&lt;/td&gt;
&lt;td&gt;49 ms, 29 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both navigation transitions included &lt;code&gt;::view-transition-old(card-2)&lt;/code&gt; and &lt;code&gt;::view-transition-new(card-2)&lt;/code&gt;, which is the shared pair.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do view transitions run on the browser back button?
&lt;/h2&gt;

&lt;p&gt;Not in my test. Using the browser's back or forward button in Next.js 16.4.2 started no view transition at all, even for the named morph. It was a client-side navigation (same document, URL and grid restored), and zero calls reached &lt;code&gt;startViewTransition&lt;/code&gt;. Use in-app back links with &lt;code&gt;transitionTypes={["nav-back"]}&lt;/code&gt; for animated returns.&lt;/p&gt;

&lt;p&gt;The Next.js guide says browser back navigations "do not carry a transition type, so the directional slide does not play," and that "the shared element morph from Step 1 still applies." In headless Chrome 155 against a production build (&lt;code&gt;next start&lt;/code&gt;), I could not reproduce the morph on back or forward. Treat back-button animation as unreliable until you have tested it in your own app and browsers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common React View Transition mistakes
&lt;/h2&gt;

&lt;p&gt;The usual problems are updates that are not Transitions, wrappers placed in layouts, duplicate names on one page, a destination that suspends before the morph can pair, and expecting the back button to animate. Each fails silently: the UI changes instantly with no error.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mistake&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plain &lt;code&gt;setState&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No animation&lt;/td&gt;
&lt;td&gt;Wrap the update in &lt;code&gt;startTransition&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrapper in &lt;code&gt;layout.tsx&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Directional enter/exit never run&lt;/td&gt;
&lt;td&gt;Put it in each &lt;code&gt;page.tsx&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same &lt;code&gt;name&lt;/code&gt; twice on a page&lt;/td&gt;
&lt;td&gt;Broken or missing morph&lt;/td&gt;
&lt;td&gt;Build names from IDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Destination suspends before the hero renders&lt;/td&gt;
&lt;td&gt;Content fades in instead of morphing&lt;/td&gt;
&lt;td&gt;Keep the shared element in the static or prefetched shell&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;default="none"&lt;/code&gt; without &lt;code&gt;share&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Pair stops morphing (per Next.js docs)&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;share="morph"&lt;/code&gt; or &lt;code&gt;share="auto"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No &lt;code&gt;pointer-events: none&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Clicks lost during animations&lt;/td&gt;
&lt;td&gt;&lt;code&gt;::view-transition { pointer-events: none; }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relying on browser back&lt;/td&gt;
&lt;td&gt;Nothing animates&lt;/td&gt;
&lt;td&gt;In-app back links with &lt;code&gt;transitionTypes&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ignoring reduced motion&lt;/td&gt;
&lt;td&gt;Slides for motion-sensitive users&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;prefers-reduced-motion&lt;/code&gt; media query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Animating cached Suspense content&lt;/td&gt;
&lt;td&gt;Content that's already loaded still fades in&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;update="auto" default="none"&lt;/code&gt; on the boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  View Transitions vs Framer Motion (Motion)
&lt;/h2&gt;

&lt;p&gt;Use View Transitions for route changes, shared elements across pages and loading reveals. The browser animates snapshots, so you add no animation library to your bundle. Keep Motion (formerly Framer Motion) for gestures, springs, drag, scroll-linked effects and animations that must respond while the user interacts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;View Transitions&lt;/th&gt;
&lt;th&gt;Motion&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Morph between routes&lt;/td&gt;
&lt;td&gt;Built in (&lt;code&gt;name&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Needs layout animations plus shared state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Suspense loading reveal&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra JavaScript&lt;/td&gt;
&lt;td&gt;None (browser API)&lt;/td&gt;
&lt;td&gt;A runtime library&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interruptible, gesture-driven&lt;/td&gt;
&lt;td&gt;No (snapshot animations; hit-testing skips named elements while they run)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Springs and physics&lt;/td&gt;
&lt;td&gt;CSS easing only&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browsers without support&lt;/td&gt;
&lt;td&gt;Instant update&lt;/td&gt;
&lt;td&gt;Animates&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the agent route, Next.js links a &lt;code&gt;vercel-react-view-transitions&lt;/code&gt; skill you can install with &lt;code&gt;npx skills add vercel-labs/agent-skills --skill vercel-react-view-transitions&lt;/code&gt;. My &lt;a href="https://www.fabwaseem.com/blog/agent-skills" rel="noopener noreferrer"&gt;Agent Skills guide&lt;/a&gt; explains what that command installs. For the partial prerendering setup used in this demo, see my &lt;a href="https://www.fabwaseem.com/blog/nextjs-use-cache" rel="noopener noreferrer"&gt;Next.js use cache guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; Next.js 16.4 also ships the experimental Rust React Compiler. I measured it against the Babel compiler and no compiler in &lt;a href="https://www.fabwaseem.com/blog/react-compiler-nextjs" rel="noopener noreferrer"&gt;React Compiler in Next.js 16.4&lt;/a&gt;: 20 counter clicks caused 0 row re-renders instead of 20,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;React View Transitions (ViewTransition and addTransitionType) are stable in React 19.3, released September 9, 2026, and work in Next.js 16.4 with no config flag.&lt;/li&gt;
&lt;li&gt;Only Transitions animate: startTransition, Suspense reveals, useDeferredValue and Next.js navigations; a plain setState does not.&lt;/li&gt;
&lt;li&gt;Give two elements the same name to morph them across routes, and keep the destination in a prefetched or static shell so they pair.&lt;/li&gt;
&lt;li&gt;Tag links with transitionTypes and put the enter/exit wrapper in each page.tsx, not the layout.&lt;/li&gt;
&lt;li&gt;In my test, browser back and forward buttons started no view transition at all, so use in-app back links for animated returns.&lt;/li&gt;
&lt;li&gt;Add pointer-events: none on ::view-transition and a prefers-reduced-motion rule to every app.&lt;/li&gt;
&lt;li&gt;Use View Transitions for page-level motion and keep Motion for gestures, springs and interactive animation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What are React View Transitions?
&lt;/h3&gt;

&lt;p&gt;React View Transitions are the ViewTransition component and the addTransitionType function, stable since React 19.3 (September 9, 2026). They use the browser View Transition API to animate elements when they enter, exit, update or move between pages, while React chooses which elements take part and which CSS class each animation uses.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do React View Transitions work in Next.js?
&lt;/h3&gt;

&lt;p&gt;Yes. Next.js 16.4 ships React 19.3, and the Next.js docs say view transitions work in the App Router with no configuration. The old experimental.viewTransition flag is gone. Import ViewTransition from react, and every Link or router.push navigation animates because it is a Transition.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my ViewTransition not animating?
&lt;/h3&gt;

&lt;p&gt;Most often the update is not a Transition. A plain setState never animates; wrap it in startTransition. Other causes: the wrapper sits in a layout instead of page.tsx, two elements share a name on one page, the browser lacks support, or the navigation came from the browser back button.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I make a shared element transition in Next.js?
&lt;/h3&gt;

&lt;p&gt;Wrap the element on both pages in a ViewTransition with the same unique name, such as card-2, and add share="morph" with default="none". React pairs them across the navigation and the browser animates size and position. The destination element must render in the same commit, so keep it in the prefetched shell.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does addTransitionType do?
&lt;/h3&gt;

&lt;p&gt;addTransitionType tags the current Transition with a reason, such as next or previous. Call it inside startTransition next to the state update, then map types to CSS classes in the enter, exit or update props. React also passes each type to the browser, so :active-view-transition-type() works in CSS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do view transitions work with the browser back button?
&lt;/h3&gt;

&lt;p&gt;Not reliably. In my test with Next.js 16.4.2 and Chrome 155, the browser back and forward buttons started no view transition at all, even for named shared elements. Animated returns worked through in-app back links using transitionTypes={["nav-back"]}.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which browsers support React View Transitions?
&lt;/h3&gt;

&lt;p&gt;Next.js says the integration needs Chromium 125 or newer and recent Safari and Firefox versions, and some animations may behave differently in Safari. In browsers without support the app works normally and updates happen instantly, without animation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use View Transitions or Framer Motion?
&lt;/h3&gt;

&lt;p&gt;Use View Transitions for route changes, cross-page shared elements and Suspense reveals; they add no animation library because the browser animates snapshots. Keep Motion (formerly Framer Motion) for gestures, springs, drag, scroll-linked effects and animations that must stay interactive while they run.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/react-view-transitions" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Next.js Server Actions vs Route Handlers (API Routes): When to Use Each</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Sat, 24 Oct 2026 19:08:57 +0000</pubDate>
      <link>https://dev.to/fabwaseem/nextjs-server-actions-vs-route-handlers-api-routes-when-to-use-each-okc</link>
      <guid>https://dev.to/fabwaseem/nextjs-server-actions-vs-route-handlers-api-routes-when-to-use-each-okc</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/server-actions-vs-route-handlers" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Server Actions vs Route Handlers&lt;/strong&gt; comes down to one question: who is calling your server code? Use a &lt;strong&gt;Server Action&lt;/strong&gt; when your own Next.js UI mutates data (forms, buttons, optimistic updates). Use a &lt;strong&gt;Route Handler&lt;/strong&gt; when anything else needs a real HTTP endpoint: webhooks, mobile apps, public APIs, OAuth callbacks, file downloads, or cached &lt;code&gt;GET&lt;/code&gt; JSON. For reads, use neither; fetch directly in a Server Component.&lt;/p&gt;

&lt;p&gt;This guide is written against &lt;strong&gt;Next.js 16.4 and React 19.3&lt;/strong&gt;, the current stable releases. Every code sample below was built with a fresh &lt;code&gt;create-next-app&lt;/code&gt; project (which now enables Cache Components by default), type-checked, linted, and run with &lt;code&gt;next start&lt;/code&gt;, and the Route Handler responses shown are the real ones I got back. Where I state a framework behavior, it links to the official Next.js or React docs.&lt;/p&gt;

&lt;p&gt;If you are building an API for AI agents, the same rules apply: the MCP endpoint in my &lt;a href="https://www.fabwaseem.com/blog/build-mcp-server-nextjs" rel="noopener noreferrer"&gt;MCP server in Next.js guide&lt;/a&gt; is a Route Handler, because an external client calls it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the difference between Server Actions and Route Handlers?
&lt;/h2&gt;

&lt;p&gt;A Server Action is an async function marked with &lt;code&gt;'use server'&lt;/code&gt; that your React components call like a normal function; Next.js turns the call into a &lt;code&gt;POST&lt;/code&gt; request and can return fresh UI in the same response. A Route Handler is a &lt;code&gt;route.ts&lt;/code&gt; file that exports &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;POST&lt;/code&gt; and other HTTP methods and returns a standard Web &lt;code&gt;Response&lt;/code&gt; to any client.&lt;/p&gt;

&lt;p&gt;In other words, a Server Action is an internal RPC call for your own frontend, and a Route Handler is a public URL with a contract. Both run only on the server, both can read cookies and talk to your database, and both are reachable by anyone who sends the right request, so both need authentication.&lt;/p&gt;

&lt;h3&gt;
  
  
  Quick definitions
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server Function&lt;/td&gt;
&lt;td&gt;Any async function marked with &lt;code&gt;'use server'&lt;/code&gt; that the client can call over the network (&lt;a href="https://react.dev/reference/rsc/use-server" rel="noopener noreferrer"&gt;React docs&lt;/a&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server Action&lt;/td&gt;
&lt;td&gt;A Server Function used for a mutation, through &lt;code&gt;&amp;lt;form action&amp;gt;&lt;/code&gt;, &lt;code&gt;formAction&lt;/code&gt; or &lt;code&gt;startTransition&lt;/code&gt; (&lt;a href="https://nextjs.org/docs/app/guides/server-actions" rel="noopener noreferrer"&gt;Next.js docs&lt;/a&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route Handler&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;route.ts&lt;/code&gt; file in &lt;code&gt;app/&lt;/code&gt; that handles HTTP requests with the Web &lt;code&gt;Request&lt;/code&gt; and &lt;code&gt;Response&lt;/code&gt; APIs (&lt;a href="https://nextjs.org/docs/app/getting-started/route-handlers" rel="noopener noreferrer"&gt;Next.js docs&lt;/a&gt;).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API Route&lt;/td&gt;
&lt;td&gt;The Pages Router equivalent of a Route Handler, in &lt;code&gt;pages/api&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RSC Payload&lt;/td&gt;
&lt;td&gt;The serialized Server Component tree the client uses to update the page.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  TL;DR: when to use Server Actions vs Route Handlers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You need to...&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Submit a form or mutate data from your own UI&lt;/td&gt;
&lt;td&gt;Server Action&lt;/td&gt;
&lt;td&gt;Progressive enhancement, typed arguments, fresh UI in one roundtrip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Show pending, error and success states for a mutation&lt;/td&gt;
&lt;td&gt;Server Action + &lt;code&gt;useActionState&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The hook gives you &lt;code&gt;state&lt;/code&gt;, &lt;code&gt;formAction&lt;/code&gt; and &lt;code&gt;pending&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read data for a page&lt;/td&gt;
&lt;td&gt;Server Component&lt;/td&gt;
&lt;td&gt;Fetch straight from the data source; no extra HTTP hop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Receive a webhook (Stripe, GitHub, a CMS)&lt;/td&gt;
&lt;td&gt;Route Handler&lt;/td&gt;
&lt;td&gt;Fixed URL, raw body for signature checks, custom status codes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serve a mobile app, CLI or third-party client&lt;/td&gt;
&lt;td&gt;Route Handler&lt;/td&gt;
&lt;td&gt;A stable, documented HTTP contract that does not change per build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Return non-HTML content (RSS, CSV, images, files)&lt;/td&gt;
&lt;td&gt;Route Handler&lt;/td&gt;
&lt;td&gt;Return any &lt;code&gt;Response&lt;/code&gt; with any content type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Handle an OAuth or payment callback URL&lt;/td&gt;
&lt;td&gt;Route Handler&lt;/td&gt;
&lt;td&gt;Third parties redirect to a known URL with query params&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Poll or fetch client-side (SWR, TanStack Query)&lt;/td&gt;
&lt;td&gt;Route Handler&lt;/td&gt;
&lt;td&gt;Server Actions run one at a time and are not meant for reads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expose a cacheable &lt;code&gt;GET&lt;/code&gt; JSON endpoint&lt;/td&gt;
&lt;td&gt;Route Handler&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GET&lt;/code&gt; handlers can opt into caching; actions are always &lt;code&gt;POST&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl0142yiyz8bsc23ml6qz.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl0142yiyz8bsc23ml6qz.jpg" alt="Decision flow for choosing between a Next.js Server Action, Route Handler or Server Component" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What are Server Actions in Next.js?
&lt;/h2&gt;

&lt;p&gt;Server Actions are async server functions that Next.js exposes to your components through the &lt;code&gt;'use server'&lt;/code&gt; directive. At build time the compiler replaces the function in client bundles with an encrypted action ID, so calling it sends a &lt;code&gt;POST&lt;/code&gt; to the current page. Next.js can then return both the action's result and a re-rendered page in a single response.&lt;/p&gt;

&lt;p&gt;Three details from the official docs matter for the decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Only &lt;code&gt;POST&lt;/code&gt; can invoke them.&lt;/strong&gt; The &lt;a href="https://nextjs.org/docs/app/getting-started/mutating-data" rel="noopener noreferrer"&gt;Mutating Data docs&lt;/a&gt; state that actions use &lt;code&gt;POST&lt;/code&gt; behind the scenes and only that method can call them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One response carries data and UI.&lt;/strong&gt; If the action calls &lt;code&gt;revalidatePath&lt;/code&gt;, &lt;code&gt;updateTag&lt;/code&gt;, &lt;code&gt;refresh&lt;/code&gt;, sets a cookie or redirects, Next.js re-renders the current route and streams it back in the same response, so you do not need a follow-up fetch (&lt;a href="https://nextjs.org/docs/app/guides/server-actions" rel="noopener noreferrer"&gt;Server Actions guide&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They are dispatched one at a time per client.&lt;/strong&gt; If a user triggers three actions quickly, the second waits for the first. The docs say not to rely on &lt;code&gt;Promise.all&lt;/code&gt; to parallelize them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Forms that call Server Actions from Server Components also work before JavaScript loads, which is progressive enhancement you do not get from a &lt;code&gt;fetch&lt;/code&gt; call to an API.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are Route Handlers in Next.js?
&lt;/h2&gt;

&lt;p&gt;Route Handlers are &lt;code&gt;route.ts&lt;/code&gt; files inside the &lt;code&gt;app&lt;/code&gt; directory that export functions named after HTTP methods. They support &lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;POST&lt;/code&gt;, &lt;code&gt;PUT&lt;/code&gt;, &lt;code&gt;PATCH&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;HEAD&lt;/code&gt; and &lt;code&gt;OPTIONS&lt;/code&gt;, return a &lt;code&gt;405&lt;/code&gt; for anything else, and work with the standard Web &lt;code&gt;Request&lt;/code&gt; and &lt;code&gt;Response&lt;/code&gt; APIs, plus Next.js helpers &lt;code&gt;NextRequest&lt;/code&gt; and &lt;code&gt;NextResponse&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A few rules from the &lt;a href="https://nextjs.org/docs/app/getting-started/route-handlers" rel="noopener noreferrer"&gt;Route Handlers docs&lt;/a&gt; shape how you use them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;route.ts&lt;/code&gt; cannot sit in the same segment as a &lt;code&gt;page.tsx&lt;/code&gt; (&lt;code&gt;app/route.ts&lt;/code&gt; next to &lt;code&gt;app/page.tsx&lt;/code&gt; is a conflict; &lt;code&gt;app/api/route.ts&lt;/code&gt; is fine).&lt;/li&gt;
&lt;li&gt;Route Handlers are &lt;strong&gt;not cached by default&lt;/strong&gt;. You can opt &lt;code&gt;GET&lt;/code&gt; into caching; other methods are never cached.&lt;/li&gt;
&lt;li&gt;With Cache Components enabled, &lt;code&gt;GET&lt;/code&gt; handlers follow the same model as pages: they are prerendered when they touch no runtime data, and you can cache a helper with &lt;code&gt;'use cache'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;They do not take part in layouts or client-side navigation. They are the lowest-level routing primitive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Next.js &lt;a href="https://nextjs.org/docs/app/guides/backend-for-frontend" rel="noopener noreferrer"&gt;Backend for Frontend guide&lt;/a&gt; sums up their role: Route Handlers are public HTTP endpoints, and any client can access them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server Actions vs Route Handlers: side-by-side comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Server Action&lt;/th&gt;
&lt;th&gt;Route Handler&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Defined in&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'use server'&lt;/code&gt; file or inline function&lt;/td&gt;
&lt;td&gt;&lt;code&gt;app/**/route.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP methods&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;POST&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GET&lt;/code&gt;, &lt;code&gt;POST&lt;/code&gt;, &lt;code&gt;PUT&lt;/code&gt;, &lt;code&gt;PATCH&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;HEAD&lt;/code&gt;, &lt;code&gt;OPTIONS&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;URL&lt;/td&gt;
&lt;td&gt;No stable public URL; called by encrypted action ID&lt;/td&gt;
&lt;td&gt;Fixed URL you choose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical caller&lt;/td&gt;
&lt;td&gt;Your own React components&lt;/td&gt;
&lt;td&gt;Any HTTP client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Input&lt;/td&gt;
&lt;td&gt;Serialized function arguments or &lt;code&gt;FormData&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Request&lt;/code&gt; (JSON, form data, raw text, streams)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Output&lt;/td&gt;
&lt;td&gt;Serializable return value plus fresh RSC Payload&lt;/td&gt;
&lt;td&gt;Any &lt;code&gt;Response&lt;/code&gt; and content type&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caching&lt;/td&gt;
&lt;td&gt;Never cached&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;GET&lt;/code&gt; can opt in; others never cached&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency from one client&lt;/td&gt;
&lt;td&gt;Dispatched one at a time&lt;/td&gt;
&lt;td&gt;Fully parallel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Form integration&lt;/td&gt;
&lt;td&gt;Built in; &lt;code&gt;&amp;lt;form action&amp;gt;&lt;/code&gt; works before JavaScript loads&lt;/td&gt;
&lt;td&gt;None; you wire up &lt;code&gt;fetch&lt;/code&gt; or a form post yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Built-in CSRF check&lt;/td&gt;
&lt;td&gt;Yes, &lt;code&gt;Origin&lt;/code&gt; vs &lt;code&gt;Host&lt;/code&gt; comparison&lt;/td&gt;
&lt;td&gt;No; add your own for cookie-auth mutations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default body size limit&lt;/td&gt;
&lt;td&gt;1 MB (configurable)&lt;/td&gt;
&lt;td&gt;No Server Action limit applies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Immediate cache update&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;updateTag&lt;/code&gt; (Server Actions only)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;revalidateTag(tag, 'max')&lt;/code&gt;, &lt;code&gt;revalidatePath&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Survives a new deploy&lt;/td&gt;
&lt;td&gt;IDs can change between builds&lt;/td&gt;
&lt;td&gt;URL stays the same&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last row is easy to miss. Next.js docs note that new deployments typically generate new action IDs, rotated at most every 14 days even with unchanged code, so a tab still running an old build can hit a "&lt;a href="https://nextjs.org/docs/messages/failed-to-find-server-action" rel="noopener noreferrer"&gt;Failed to find Server Action&lt;/a&gt;" error. That is fine for your own UI, which reloads, but it rules Server Actions out as an API for clients you do not ship with each build.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should you use Server Actions?
&lt;/h2&gt;

&lt;p&gt;Use a Server Action when your own Next.js UI changes data and the user should see the result right away: creating, editing or deleting records, toggling settings, submitting forms, liking a post, or updating a cart. You get typed calls, progressive enhancement, built-in pending state and one-roundtrip revalidation without writing an API contract.&lt;/p&gt;

&lt;p&gt;Here is a complete, working example. The action authenticates, validates with Zod, mutates, and revalidates:&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/todos/actions.ts&lt;/span&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;revalidatePath&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getUserId&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/auth&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;createTodo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TodoInput&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/todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;FormState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;message&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;addTodo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_prev&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FormState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FormData&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;FormState&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Authenticate inside the action. It is a public POST endpoint.&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getUserId&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;{&lt;/span&gt; &lt;span class="na"&gt;ok&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="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Please sign in first.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Validate untrusted input.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;TodoInput&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;formData&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;title&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;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;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&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="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parsed&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;issues&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid input&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;// 3. Mutate, then revalidate so the same response carries fresh UI.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createTodo&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;parsed&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="nf"&gt;revalidatePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/todos&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="na"&gt;ok&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;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Todo added.&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 client form uses React's &lt;code&gt;useActionState&lt;/code&gt;, which returns the last result, a wrapped action and a &lt;code&gt;pending&lt;/code&gt; flag:&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/todos/todo-form.tsx&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&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;useActionState&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;addTodo&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;FormState&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;./actions&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;initialState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FormState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&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="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;TodoForm&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;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formAction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useActionState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addTodo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;initialState&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;form&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;formAction&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="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"title"&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"New todo"&lt;/span&gt; &lt;span class="na"&gt;required&lt;/span&gt; &lt;span class="na"&gt;maxLength&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;120&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="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"submit"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pending&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;pending&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Adding...&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;Add todo&lt;/span&gt;&lt;span class="dl"&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;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="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;p&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"status"&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&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;And the page reads data in a Server Component. Because new projects ship with Cache Components on, the cookie-dependent list sits inside &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt;; without it, &lt;code&gt;next build&lt;/code&gt; fails with a "blocking prerender" error, which I hit on the first build:&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/todos/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;Suspense&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;getUserId&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/auth&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;listTodos&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/todos&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;TodoForm&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;./todo-form&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;TodosPage&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;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Todos&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TodoForm&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;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="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Loading todos...&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;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;TodoList&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;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;main&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;span class="c1"&gt;// Reads come straight from the data layer in a Server 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;TodoList&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;userId&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;getUserId&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;todos&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;await&lt;/span&gt; &lt;span class="nf"&gt;listTodos&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="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;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;todos&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;todo&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;todo&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;todo&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="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;
  
  
  Should you use revalidatePath, updateTag or revalidateTag in a Server Action?
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;updateTag&lt;/code&gt; when the user must see their own write immediately; it expires the tag and the action's re-render waits for fresh data, and it only works inside Server Actions. Use &lt;code&gt;revalidatePath&lt;/code&gt; when one route is affected. Use &lt;code&gt;revalidateTag(tag, 'max')&lt;/code&gt; for background, stale-while-revalidate refreshes that should not slow the response.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://nextjs.org/docs/app/api-reference/functions/updateTag" rel="noopener noreferrer"&gt;updateTag docs&lt;/a&gt; are explicit: calling it from a Route Handler throws, and you should use &lt;code&gt;revalidateTag&lt;/code&gt; there instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should you use Route Handlers?
&lt;/h2&gt;

&lt;p&gt;Use a Route Handler whenever the caller is not your own React tree, or the response is not a UI update: webhooks, mobile and partner APIs, OAuth and payment callbacks, RSS feeds, file and CSV downloads, streaming responses, health checks, and cacheable public &lt;code&gt;GET&lt;/code&gt; JSON. If someone outside your codebase needs a URL, it should be a Route Handler.&lt;/p&gt;

&lt;p&gt;A JSON API for a mobile app, with bearer auth and proper status codes:&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/todos/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;NextResponse&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;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="s1"&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;ZodError&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;zod&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;getUserIdFromBearer&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/auth&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;createTodo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;listTodos&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/todos&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;GET&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="nx"&gt;userId&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;getUserIdFromBearer&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="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;authorization&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;NextResponse&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;todos&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;listTodos&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="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;POST&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="nx"&gt;userId&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;getUserIdFromBearer&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="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;authorization&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;NextResponse&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&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;todo&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;createTodo&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;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&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="nx"&gt;todo&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;201&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;ZodError&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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&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;issues&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;400&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;SyntaxError&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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Body must be valid JSON&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;400&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="nx"&gt;error&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;Against &lt;code&gt;next start&lt;/code&gt;, this returned &lt;code&gt;401&lt;/code&gt; with no token, &lt;code&gt;201&lt;/code&gt; with the created todo, &lt;code&gt;400&lt;/code&gt; with Zod's issue list for an empty title, &lt;code&gt;400&lt;/code&gt; for malformed JSON, and &lt;code&gt;405&lt;/code&gt; for an unsupported &lt;code&gt;PUT&lt;/code&gt;, all without extra code for the &lt;code&gt;405&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A webhook needs the &lt;strong&gt;raw body&lt;/strong&gt; to verify its signature, which is exactly what a Route Handler gives you and a Server Action does not:&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/payments/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;createHmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timingSafeEqual&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;node:crypto&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="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;request&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;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WEBHOOK_SECRET&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;secret&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;Webhook not configured&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;500&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="c1"&gt;// Read the raw body first: signatures are computed over the exact bytes sent.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&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;headers&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;x-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wanted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;wanted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wanted&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;Invalid signature&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;401&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="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;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&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;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;order.paid&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;orders&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;// stale-while-revalidate refresh&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;Response&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="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a correctly signed body it returned &lt;code&gt;200 {"received":true}&lt;/code&gt;; with a forged signature, &lt;code&gt;401&lt;/code&gt;. Payment providers such as Stripe use their own signature headers and SDK helpers, but the shape is the same: read text, verify, then parse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can you use Server Actions and Route Handlers together?
&lt;/h2&gt;

&lt;p&gt;Yes, and most production apps should. Put your business logic in a server-only data layer, then make Server Actions and Route Handlers thin adapters over it: the action handles your web UI, the Route Handler serves mobile apps and webhooks. Validation, authorization rules and database access live in one place, so the two entry points cannot drift apart.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1gbhojhii3ev2jx0tksn.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1gbhojhii3ev2jx0tksn.jpg" alt="One data layer shared by a Server Action for the web UI and a Route Handler for external clients" width="800" height="450"&gt;&lt;/a&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;// lib/todos.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server-only&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;z&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;zod&lt;/span&gt;&lt;span class="dl"&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;TodoInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Title is required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&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;type&lt;/span&gt; &lt;span class="nx"&gt;Todo&lt;/span&gt; &lt;span class="o"&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="nl"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;ownerId&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="nl"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// In-memory store so the example runs as-is. Swap in Prisma or Drizzle.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Todo&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

&lt;span class="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;listTodos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Todo&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;return&lt;/span&gt; &lt;span class="nx"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&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;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ownerId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;ownerId&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;createTodo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ownerId&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;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&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;Todo&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;TodoInput&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;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// throws ZodError on bad input&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;todo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Todo&lt;/span&gt; &lt;span class="o"&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="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ownerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;done&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="nx"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;todo&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;todo&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;server-only&lt;/code&gt; import makes the build fail if a Client Component ever imports this file, which keeps database code and secrets out of the browser bundle. This is the same Data Access Layer idea the &lt;a href="https://nextjs.org/docs/app/guides/data-security" rel="noopener noreferrer"&gt;Next.js Data Security guide&lt;/a&gt; recommends. In a real app, swap the array for Prisma or Drizzle, as in my open-source &lt;a href="https://www.fabwaseem.com/projects/foliora" rel="noopener noreferrer"&gt;Foliora&lt;/a&gt; (Prisma and PostgreSQL) and &lt;a href="https://www.fabwaseem.com/projects/forgekit" rel="noopener noreferrer"&gt;Forgekit&lt;/a&gt; (Drizzle and pgvector) projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you use Server Actions to fetch data?
&lt;/h2&gt;

&lt;p&gt;No. The React docs say Server Functions "are designed for mutations that update server-side state; they are not recommended for data fetching," and that frameworks typically process one action at a time and cannot cache the return value. Fetch in a Server Component instead, or use a &lt;code&gt;GET&lt;/code&gt; Route Handler when the client must fetch.&lt;/p&gt;

&lt;p&gt;The Next.js docs make the same point from the other side: Server Actions are queued, so using them for reads makes requests sequential. That is the opposite of what you want for a dashboard that loads several widgets. For client-side polling or data that depends on browser APIs, the &lt;a href="https://nextjs.org/docs/app/guides/backend-for-frontend" rel="noopener noreferrer"&gt;Backend for Frontend guide&lt;/a&gt; suggests a Route Handler with SWR or TanStack Query.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should a Server Component fetch from your own Route Handler?
&lt;/h3&gt;

&lt;p&gt;No. Call the data layer directly. The Next.js docs warn that fetching your own Route Handler from a Server Component adds an extra HTTP round trip, and for pages prerendered at build time it fails the build, because no server is listening yet. Route Handlers are for clients outside the React server render.&lt;/p&gt;

&lt;h2&gt;
  
  
  Are Server Actions secure?
&lt;/h2&gt;

&lt;p&gt;Server Actions are as secure as the checks you write inside them. Next.js adds a CSRF check (comparing &lt;code&gt;Origin&lt;/code&gt; with &lt;code&gt;Host&lt;/code&gt;), a 1 MB default body limit, encrypted action IDs, removal of unused actions from client bundles, and encryption of closed-over variables. It does not authenticate users or validate input for you, and every action is reachable by a direct &lt;code&gt;POST&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The official guidance boils down to three rules inside every action, which apply equally to Route Handlers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authenticate and authorize.&lt;/strong&gt; Hiding a form on a page is not a security boundary; requests can skip your UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate inputs.&lt;/strong&gt; Treat &lt;code&gt;FormData&lt;/code&gt;, arguments, query params and headers as untrusted. Schema validation checks shape, not ownership, so look records up by both ID and owner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Constrain return values.&lt;/strong&gt; Whatever an action returns is serialized to the client, so return only what the UI renders, never raw database rows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two configuration details matter when you self-host:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Behind a proxy or CDN&lt;/strong&gt;, add the external domains to &lt;code&gt;serverActions.allowedOrigins&lt;/code&gt; in &lt;code&gt;next.config&lt;/code&gt;, or the CSRF check can reject legitimate requests (&lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/serverActions" rel="noopener noreferrer"&gt;serverActions config&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With more than one instance&lt;/strong&gt;, set &lt;code&gt;NEXT_SERVER_ACTIONS_ENCRYPTION_KEY&lt;/code&gt; to the same value everywhere so action references decrypt on every replica. I cover replicas in the &lt;a href="https://www.fabwaseem.com/blog/nextjs-dockerfile" rel="noopener noreferrer"&gt;production Next.js Dockerfile guide&lt;/a&gt; and the &lt;a href="https://www.fabwaseem.com/blog/self-host-nextjs-with-coolify" rel="noopener noreferrer"&gt;Coolify self-hosting guide&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Route Handlers get none of the automatic protections, so for cookie-authenticated mutations you add CSRF protection, rate limiting and payload verification yourself. The Backend for Frontend guide's security section covers headers, rate limiting, payload verification and access to protected resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server Actions vs API routes: what changed from the Pages Router?
&lt;/h2&gt;

&lt;p&gt;API Routes live in &lt;code&gt;pages/api&lt;/code&gt; and use Node-style &lt;code&gt;req&lt;/code&gt; and &lt;code&gt;res&lt;/code&gt; objects. In the App Router, Route Handlers replace them and use the Web &lt;code&gt;Request&lt;/code&gt; and &lt;code&gt;Response&lt;/code&gt; APIs instead. The Next.js docs say you do not need to use both. Server Actions are new with the App Router; they became stable in Next.js 14.&lt;/p&gt;

&lt;p&gt;So when people search "Server Actions vs API routes," they usually mean the same decision as this post. If you are migrating, map each API route to a Route Handler first, then move mutations that only your UI calls into Server Actions, and leave anything an external client depends on as a Route Handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do Server Actions replace tRPC or TanStack Query?
&lt;/h2&gt;

&lt;p&gt;Partly. For mutations called only by your own Next.js UI, Server Actions give you typed calls without a separate API layer. They do not give you client-side caching, parallel reads, or a contract other clients can consume. Keep TanStack Query or SWR for client-side fetching against Route Handlers, and keep an API layer like tRPC or oRPC when you need versioned, reusable endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Server Action and Route Handler mistakes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mistake&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No auth check inside the action&lt;/td&gt;
&lt;td&gt;Anyone who can send the &lt;code&gt;POST&lt;/code&gt; can run it&lt;/td&gt;
&lt;td&gt;Authenticate and authorize inside every action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Promise.all&lt;/code&gt; over several actions&lt;/td&gt;
&lt;td&gt;They still run one after another&lt;/td&gt;
&lt;td&gt;Do parallel work inside one action or a Route Handler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Using an action to load page data&lt;/td&gt;
&lt;td&gt;Sequential, uncached reads&lt;/td&gt;
&lt;td&gt;Fetch in a Server Component&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server Component fetches &lt;code&gt;/api/...&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Extra round trip; build fails when prerendered&lt;/td&gt;
&lt;td&gt;Call the data layer directly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uploading a large file through an action&lt;/td&gt;
&lt;td&gt;Rejected above the 1 MB default&lt;/td&gt;
&lt;td&gt;Raise &lt;code&gt;bodySizeLimit&lt;/code&gt; or upload straight to storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;updateTag&lt;/code&gt; in a Route Handler&lt;/td&gt;
&lt;td&gt;Throws at runtime&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;revalidateTag(tag, 'max')&lt;/code&gt; or &lt;code&gt;revalidatePath&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mobile app calls a Server Action&lt;/td&gt;
&lt;td&gt;Breaks when action IDs change on deploy&lt;/td&gt;
&lt;td&gt;Expose a Route Handler with a stable URL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Several replicas, no shared key&lt;/td&gt;
&lt;td&gt;Action references fail on other instances&lt;/td&gt;
&lt;td&gt;Set one &lt;code&gt;NEXT_SERVER_ACTIONS_ENCRYPTION_KEY&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; Next.js 16.4.1 fixed a high-severity DoS that affects any App Router app with a Server Action, so check your version with the &lt;a href="https://www.fabwaseem.com/blog/nextjs-security-update-october-2026" rel="noopener noreferrer"&gt;Next.js security update October 2026 guide&lt;/a&gt;. To refresh cached data after a Server Action, see &lt;code&gt;updateTag&lt;/code&gt; in &lt;a href="https://www.fabwaseem.com/blog/nextjs-use-cache" rel="noopener noreferrer"&gt;Next.js use cache explained&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; auth libraries hit this split directly. Better Auth signs users in from a server action only with its &lt;code&gt;nextCookies()&lt;/code&gt; plugin, and serves everything else from a route handler. See &lt;a href="https://www.fabwaseem.com/blog/better-auth-nextjs" rel="noopener noreferrer"&gt;Better Auth with Next.js 16&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use Server Actions for mutations from your own UI, Route Handlers for any caller with a URL, and Server Components for reads.&lt;/li&gt;
&lt;li&gt;Server Actions are &lt;code&gt;POST&lt;/code&gt;-only, dispatched one at a time per client, and can return fresh UI in the same response.&lt;/li&gt;
&lt;li&gt;Route Handlers are public, uncached by default, support every common HTTP method and can return any content type.&lt;/li&gt;
&lt;li&gt;Both are public entry points: authenticate, validate and shape return values in every one.&lt;/li&gt;
&lt;li&gt;Share one server-only data layer so actions and Route Handlers stay thin and consistent.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should I use Server Actions or Route Handlers in Next.js?
&lt;/h3&gt;

&lt;p&gt;Use Server Actions for data mutations triggered by your own Next.js UI, such as form submissions, toggles and deletes. Use Route Handlers when an external client needs a stable URL: webhooks, mobile apps, third-party integrations, OAuth callbacks, file downloads or cacheable GET endpoints. For reading page data, fetch directly in a Server Component.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use Server Actions to fetch data?
&lt;/h3&gt;

&lt;p&gt;You can, but you should not. The React docs say Server Functions are designed for mutations and are not recommended for data fetching, and Next.js dispatches actions one at a time per client, so reads become sequential and uncached. Fetch in Server Components, or use a GET Route Handler with SWR or TanStack Query on the client.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are Next.js Server Actions secure?
&lt;/h3&gt;

&lt;p&gt;They are as secure as the code inside them. Next.js adds a CSRF check comparing Origin and Host, a 1 MB default body limit, encrypted action IDs and removal of unused actions, but every action is still reachable by a direct POST. Authenticate, authorize and validate input inside each action, and return only the fields the UI needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are Route Handlers the same as API routes?
&lt;/h3&gt;

&lt;p&gt;Route Handlers are the App Router equivalent of Pages Router API routes. They live in route.ts files inside the app directory and use the Web Request and Response APIs instead of Node-style req and res objects. The Next.js docs note you do not need to use API routes and Route Handlers together.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a mobile app call a Next.js Server Action?
&lt;/h3&gt;

&lt;p&gt;It should not. Server Actions have no stable public URL; they are called by an encrypted action ID that can change between deployments, which causes a Failed to find Server Action error for out-of-date clients. Expose a Route Handler with a fixed URL, bearer authentication and documented JSON for mobile and third-party clients.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the Server Actions body size limit?
&lt;/h3&gt;

&lt;p&gt;By default, Next.js caps the request body sent to a Server Action at 1 MB to prevent excessive parsing and potential DDoS attacks. You can raise it with serverActions.bodySizeLimit in next.config, for example '2mb', but for large files it is usually better to upload directly to object storage.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/server-actions-vs-route-handlers" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>SSRF Protection in Node.js: Validating User URLs, Redirects and DNS (Tested)</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Sat, 24 Oct 2026 19:08:45 +0000</pubDate>
      <link>https://dev.to/fabwaseem/ssrf-protection-in-nodejs-validating-user-urls-redirects-and-dns-tested-b3e</link>
      <guid>https://dev.to/fabwaseem/ssrf-protection-in-nodejs-validating-user-urls-redirects-and-dns-tested-b3e</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/ssrf-protection-nodejs" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If your server fetches a URL that a user typed in, you have an SSRF problem until you prove otherwise. Link previews, webhook senders, "import from URL" buttons, image proxies and video downloaders all do it. Point one of them at &lt;code&gt;http://169.254.169.254/latest/meta-data/&lt;/code&gt; on a cloud VM, or at an admin panel on &lt;code&gt;127.0.0.1&lt;/code&gt;, and your server becomes the attacker's browser inside your network.&lt;/p&gt;

&lt;p&gt;This post is about the approach I use in Snagbox, my video download API, where users paste links from social media all day. I ran three different checks against the same list of bypass payloads on Node.js 20.19 to show where each one breaks. The short version: &lt;strong&gt;a regex on the hostname is not SSRF protection&lt;/strong&gt;. You need to check the parsed host, every redirect hop, and the IP address the DNS lookup actually returns.&lt;/p&gt;

&lt;h2&gt;
  
  
  What SSRF is and who is exposed
&lt;/h2&gt;

&lt;p&gt;Server-Side Request Forgery happens when an attacker controls where your server sends a request. The request comes from inside your network, so it can reach things the attacker cannot reach directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cloud metadata endpoints such as &lt;code&gt;169.254.169.254&lt;/code&gt; (AWS, GCP, Azure) and &lt;code&gt;metadata.google.internal&lt;/code&gt;, which can hand out temporary credentials&lt;/li&gt;
&lt;li&gt;Services bound to &lt;code&gt;127.0.0.1&lt;/code&gt;: Redis, admin dashboards, debug ports&lt;/li&gt;
&lt;li&gt;Private ranges such as &lt;code&gt;10.0.0.0/8&lt;/code&gt;, &lt;code&gt;172.16.0.0/12&lt;/code&gt; and &lt;code&gt;192.168.0.0/16&lt;/code&gt;, plus IPv6 unique-local addresses (&lt;code&gt;fc00::/7&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Any feature that takes a URL from a user and fetches it on the server is exposed. That includes webhooks, where the "user" is whoever configures the integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test: three checks, one payload list
&lt;/h2&gt;

&lt;p&gt;I compared three approaches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;(a) Regex on the raw string.&lt;/strong&gt; Something like &lt;code&gt;/^(https?:\/\/)?(localhost|127\.|10\.|192\.168\.|169\.254\.)/&lt;/code&gt;. This is the check I see most often in code review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;(b) &lt;code&gt;new URL()&lt;/code&gt; plus a string blocklist.&lt;/strong&gt; Parse the URL first, then compare &lt;code&gt;url.hostname&lt;/code&gt; to a list such as &lt;code&gt;localhost&lt;/code&gt;, &lt;code&gt;127.0.0.1&lt;/code&gt;, &lt;code&gt;0.0.0.0&lt;/code&gt;, &lt;code&gt;169.254.169.254&lt;/code&gt;, &lt;code&gt;[::1]&lt;/code&gt; and a few prefixes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;(c) The Snagbox &lt;code&gt;safe-fetch.ts&lt;/code&gt; approach.&lt;/strong&gt; Parse the URL, allow only &lt;code&gt;http:&lt;/code&gt; and &lt;code&gt;https:&lt;/code&gt;, reject credentials, reject internal names, and check IP literals with Node's &lt;code&gt;net.BlockList&lt;/code&gt;. DNS answers and redirects are checked separately (covered below).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Results, run on the box with &lt;code&gt;tsx&lt;/code&gt; on Node.js 20.19.2. "allowed" means the check let the URL through:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Payload&lt;/th&gt;
&lt;th&gt;new URL().hostname&lt;/th&gt;
&lt;th&gt;(a) regex&lt;/th&gt;
&lt;th&gt;(b) URL+list&lt;/th&gt;
&lt;th&gt;(c) safe-fetch static&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://127.0.0.1/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;127.0.0.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://2130706433/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;127.0.0.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://0x7f000001/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;127.0.0.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://0177.0.0.1/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;127.0.0.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://127.1/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;127.0.0.1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://[::1]/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[::1]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://[::ffff:127.0.0.1]/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[::ffff:7f00:1]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://[::ffff:7f00:1]/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[::ffff:7f00:1]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://169.254.169.254/latest/meta-data/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;169.254.169.254&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://[fd00::1]/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[fd00::1]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://0.0.0.0/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.0.0.0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://LOCALHOST./&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;localhost.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://metadata.google.internal/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;metadata.google.internal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://user:pw@example.com/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;file:///etc/passwd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;``&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://localtest.me/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;localtest.me&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;allowed&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;&lt;strong&gt;1. The WHATWG URL parser already normalizes most IPv4 tricks.&lt;/strong&gt; &lt;code&gt;new URL("http://2130706433/").hostname&lt;/code&gt; is &lt;code&gt;127.0.0.1&lt;/code&gt;. So are &lt;code&gt;0x7f000001&lt;/code&gt;, &lt;code&gt;0177.0.0.1&lt;/code&gt; and &lt;code&gt;127.1&lt;/code&gt;. That is why approach (b) catches them and the raw-string regex in (a) does not. If you only take one thing from this post: &lt;strong&gt;always parse first, then check &lt;code&gt;url.hostname&lt;/code&gt;, never the raw input.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. IPv6 is where string lists fall apart.&lt;/strong&gt; The parser turns &lt;code&gt;[::ffff:127.0.0.1]&lt;/code&gt; into &lt;code&gt;[::ffff:7f00:1]&lt;/code&gt;, an IPv4-mapped IPv6 address that still points at loopback. No sane string list contains that form. &lt;code&gt;fd00::1&lt;/code&gt; (unique-local) also slips through. &lt;code&gt;net.BlockList&lt;/code&gt; handles both: in my tests it blocked &lt;code&gt;::ffff:a9fe:a9fe&lt;/code&gt; (the hex-mapped form of &lt;code&gt;169.254.169.254&lt;/code&gt;) against the IPv4 &lt;code&gt;169.254.0.0/16&lt;/code&gt; rule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A hostname check alone cannot see DNS.&lt;/strong&gt; &lt;code&gt;localtest.me&lt;/code&gt; is a public name that resolves to &lt;code&gt;127.0.0.1&lt;/code&gt; on the public internet. Every static check, including (c), lets it through, because nothing about the string says "loopback". That is expected. The DNS answer has to be checked at connect time, which is the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking IP addresses with net.BlockList
&lt;/h2&gt;

&lt;p&gt;Node.js has had &lt;code&gt;net.BlockList&lt;/code&gt; since v15. It does subnet matching for IPv4 and IPv6, so you don't have to write CIDR math yourself:&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;ts&lt;br&gt;
import { BlockList, isIP } from "net";&lt;/p&gt;

&lt;p&gt;const blocked = new BlockList();&lt;br&gt;
blocked.addSubnet("0.0.0.0", 8, "ipv4");&lt;br&gt;
blocked.addSubnet("10.0.0.0", 8, "ipv4");&lt;br&gt;
blocked.addSubnet("127.0.0.0", 8, "ipv4");&lt;br&gt;
blocked.addSubnet("169.254.0.0", 16, "ipv4");&lt;br&gt;
blocked.addSubnet("172.16.0.0", 12, "ipv4");&lt;br&gt;
blocked.addSubnet("192.168.0.0", 16, "ipv4");&lt;br&gt;
blocked.addRange("224.0.0.0", "255.255.255.255", "ipv4");&lt;br&gt;
blocked.addAddress("::", "ipv6");&lt;br&gt;
blocked.addAddress("::1", "ipv6");&lt;br&gt;
blocked.addSubnet("fc00::", 7, "ipv6");&lt;br&gt;
blocked.addSubnet("fe80::", 10, "ipv6");&lt;br&gt;
blocked.addSubnet("ff00::", 8, "ipv6");&lt;/p&gt;

&lt;p&gt;export function isBlockedIp(address: string): boolean {&lt;br&gt;
  let ip = address.trim().toLowerCase().replace(/^[|]$/g, "");&lt;br&gt;
  const mapped = /^::ffff:(\d{1,3}(?:.\d{1,3}){3})$/.exec(ip);&lt;br&gt;
  if (mapped) ip = mapped[1];&lt;br&gt;
  const family = isIP(ip);&lt;br&gt;
  if (family === 0) return true; // not an IP at all: fail closed&lt;br&gt;
  return blocked.check(ip, family === 6 ? "ipv6" : "ipv4");&lt;br&gt;
}&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;Note the last two lines: anything that isn't a valid IP is treated as blocked. Fail closed.&lt;/p&gt;

&lt;p&gt;The URL-level check wraps this:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`ts&lt;br&gt;
export function assertPublicHttpUrl(raw: string): URL {&lt;br&gt;
  let url: URL;&lt;br&gt;
  try {&lt;br&gt;
    url = new URL(raw);&lt;br&gt;
  } catch {&lt;br&gt;
    throw new SsrfError("The URL is not a valid http(s) address.");&lt;br&gt;
  }&lt;br&gt;
  if (url.protocol !== "http:" &amp;amp;&amp;amp; url.protocol !== "https:") {&lt;br&gt;
    throw new SsrfError("Only http and https URLs can be fetched.");&lt;br&gt;
  }&lt;br&gt;
  if (url.username || url.password) {&lt;br&gt;
    throw new SsrfError("URLs with embedded credentials are not allowed.");&lt;br&gt;
  }&lt;br&gt;
  if (isBlockedHostname(url.hostname)) {&lt;br&gt;
    throw new SsrfError("Refusing to connect to a non-public address.");&lt;br&gt;
  }&lt;br&gt;
  return url;&lt;br&gt;
}&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;isBlockedHostname&lt;/code&gt; strips a trailing dot (so &lt;code&gt;LOCALHOST.&lt;/code&gt; doesn't sneak past, which it did in approach (b)), rejects &lt;code&gt;localhost&lt;/code&gt;, &lt;code&gt;*.localhost&lt;/code&gt;, &lt;code&gt;*.local&lt;/code&gt; and &lt;code&gt;*.internal&lt;/code&gt;, and runs IP literals through &lt;code&gt;isBlockedIp&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check every redirect hop
&lt;/h2&gt;

&lt;p&gt;This is the bug that bites people who did everything else right. You validate the URL, then call &lt;code&gt;fetch()&lt;/code&gt;, and &lt;code&gt;fetch()&lt;/code&gt; follows redirects on its own.&lt;/p&gt;

&lt;p&gt;I tested it with two local servers: a "front" one that answers &lt;code&gt;302 Location: http://127.0.0.1:&amp;lt;port&amp;gt;/&lt;/code&gt;, and an "internal" one that returns &lt;code&gt;internal-secret&lt;/code&gt;. Plain &lt;code&gt;fetch(frontUrl)&lt;/code&gt; came back with the body &lt;code&gt;internal-secret&lt;/code&gt;. The redirect was followed with no check at all.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;fetch(frontUrl, { redirect: "manual" })&lt;/code&gt; you get the &lt;code&gt;302&lt;/code&gt; and its &lt;code&gt;Location&lt;/code&gt; header back instead, and you decide what to do. In Snagbox this is a loop that re-validates each hop:&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;ts&lt;br&gt;
for (let hop = 0; ; hop++) {&lt;br&gt;
  const response = await transport(current, { ...options, headers: hopHeaders });&lt;br&gt;
  const isRedirect =&lt;br&gt;
    response.status &amp;gt;= 300 &amp;amp;&amp;amp; response.status &amp;lt; 400 &amp;amp;&amp;amp; response.headers.location;&lt;br&gt;
  if (!isRedirect) return wrap(response, current);&lt;/p&gt;

&lt;p&gt;response.body.resume(); // drain the redirect body&lt;br&gt;
  if (hop &amp;gt;= maxRedirects) throw new SsrfError("Too many redirects.");&lt;br&gt;
  const next = assertPublicHttpUrl(&lt;br&gt;
    new URL(String(response.headers.location), current).toString(),&lt;br&gt;
  );&lt;br&gt;
  hopHeaders = headersForHop(current, next, hopHeaders); // drop auth/cookies cross-host&lt;br&gt;
  current = next;&lt;br&gt;
}&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;In the test, a fake public host &lt;code&gt;short.example&lt;/code&gt; answering &lt;code&gt;302&lt;/code&gt; to &lt;code&gt;http://169.254.169.254/latest/meta-data/&lt;/code&gt; was refused with &lt;code&gt;SsrfError: Refusing to connect to a non-public address.&lt;/code&gt; The metadata request was never made.&lt;/p&gt;

&lt;p&gt;This matters a lot for a downloader, because users paste short links (&lt;code&gt;t.co&lt;/code&gt;, &lt;code&gt;fb.watch&lt;/code&gt;, &lt;code&gt;vm.tiktok.com&lt;/code&gt;) all the time. A public short link that bounces to an internal address is the textbook bypass.&lt;/p&gt;

&lt;p&gt;Two extra details in that loop are worth copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resolve relative &lt;code&gt;Location&lt;/code&gt; headers&lt;/strong&gt; against the current URL with &lt;code&gt;new URL(location, current)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop &lt;code&gt;Authorization&lt;/code&gt;, &lt;code&gt;Cookie&lt;/code&gt; and &lt;code&gt;Proxy-Authorization&lt;/code&gt;&lt;/strong&gt; when a redirect changes host, so you don't leak credentials to whoever the redirect points at.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  DNS rebinding: check the address you actually connect to
&lt;/h2&gt;

&lt;p&gt;The static check passed &lt;code&gt;localtest.me&lt;/code&gt;. To close that, you check the DNS answer, and you do it at connect time, inside the same request that opens the socket.&lt;/p&gt;

&lt;p&gt;Why at connect time? If you resolve the name, check it, then call &lt;code&gt;fetch(url)&lt;/code&gt;, the HTTP client resolves the name &lt;em&gt;again&lt;/em&gt;. An attacker who controls the DNS server can answer with a public IP the first time and &lt;code&gt;127.0.0.1&lt;/code&gt; the second time, with a TTL of zero. That's DNS rebinding.&lt;/p&gt;

&lt;p&gt;Node's &lt;code&gt;http.request&lt;/code&gt; and &lt;code&gt;https.request&lt;/code&gt; take a &lt;code&gt;lookup&lt;/code&gt; option. Whatever that function returns is the address the socket connects to, so checking inside it closes the gap:&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;ts&lt;br&gt;
import { lookup as dnsLookup, type LookupAddress } from "dns";&lt;/p&gt;

&lt;p&gt;function safeLookup(hostname, options, callback) {&lt;br&gt;
  dnsLookup(hostname, { all: true, verbatim: true }, (err, addresses) =&amp;gt; {&lt;br&gt;
    if (err) return callback(err, "");&lt;br&gt;
    const list = addresses as LookupAddress[];&lt;br&gt;
    if (list.length === 0 || list.some((a) =&amp;gt; isBlockedIp(a.address))) {&lt;br&gt;
      const e = new SsrfError(&lt;code&gt;Refusing to connect to a non-public address for ${hostname}.&lt;/code&gt;);&lt;br&gt;
      (e as NodeJS.ErrnoException).code = "ESSRF";&lt;br&gt;
      return callback(e, "");&lt;br&gt;
    }&lt;br&gt;
    if ((options as { all?: boolean }).all) return callback(null, list);&lt;br&gt;
    callback(null, list[0].address, list[0].family);&lt;br&gt;
  });&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;https.request(url, { lookup: safeLookup, timeout: 30_000 }, onResponse);&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;Two choices here are deliberate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;all: true&lt;/code&gt; and reject if any address is private.&lt;/strong&gt; A name that returns one public and one private address is refused. In my test, &lt;code&gt;["93.184.216.34", "127.0.0.1"]&lt;/code&gt; was blocked, and &lt;code&gt;["::ffff:169.254.169.254"]&lt;/code&gt; was blocked too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The check runs on the exact address used for the connection&lt;/strong&gt;, so there is no second lookup to race.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use &lt;code&gt;undici&lt;/code&gt; or the global &lt;code&gt;fetch()&lt;/code&gt; instead, the same idea applies through a custom &lt;code&gt;Agent&lt;/code&gt; with a &lt;code&gt;connect&lt;/code&gt; option that does its own lookup. Checking once before &lt;code&gt;fetch()&lt;/code&gt; is not enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gaps I found in my own blocklist
&lt;/h2&gt;

&lt;p&gt;Running the payloads also showed what &lt;em&gt;my&lt;/em&gt; list misses. These all came back "allowed" from &lt;code&gt;isBlockedIp&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;100.64.0.1&lt;/code&gt;: carrier-grade NAT (&lt;code&gt;100.64.0.0/10&lt;/code&gt;), used inside some cloud and Kubernetes networks&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;198.18.0.1&lt;/code&gt;: the benchmarking range (&lt;code&gt;198.18.0.0/15&lt;/code&gt;), which some sandboxes and proxies use for internal addresses. My test box's resolver returned this range for &lt;code&gt;localtest.me&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;192.0.0.1&lt;/code&gt;: IETF protocol assignments (&lt;code&gt;192.0.0.0/24&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;64:ff9b::7f00:1&lt;/code&gt;: the NAT64 prefix (&lt;code&gt;64:ff9b::/96&lt;/code&gt;) wrapping &lt;code&gt;127.0.0.1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fec0::1&lt;/code&gt;: deprecated IPv6 site-local (&lt;code&gt;fec0::/10&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are the common attack targets, but on some networks they lead somewhere internal. The fix is a few more lines:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;`ts&lt;br&gt;
blocked.addSubnet("100.64.0.0", 10, "ipv4");&lt;br&gt;
blocked.addSubnet("192.0.0.0", 24, "ipv4");&lt;br&gt;
blocked.addSubnet("198.18.0.0", 15, "ipv4");&lt;br&gt;
blocked.addSubnet("64:ff9b::", 96, "ipv6");&lt;br&gt;
blocked.addSubnet("fec0::", 10, "ipv6");&lt;br&gt;
`&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The general lesson: start from "only globally routable unicast is allowed", not from a short list of famous bad ranges.&lt;/p&gt;

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

&lt;p&gt;Not much. On my box, &lt;code&gt;isBlockedIp&lt;/code&gt; took about 1.8 µs per call, averaged over 100,000 calls (mixed public and mapped-private input). The DNS lookup itself costs whatever your resolver costs, and you were doing that lookup anyway. Moving it into the &lt;code&gt;lookup&lt;/code&gt; hook doesn't add a second one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests to keep in CI
&lt;/h2&gt;

&lt;p&gt;Turn the payload list into a test file so a refactor can't quietly undo this. With the built-in runner:&lt;/p&gt;

&lt;p&gt;`&lt;code&gt;&lt;/code&gt;ts&lt;br&gt;
import { test } from "node:test";&lt;br&gt;
import assert from "node:assert/strict";&lt;br&gt;
import { assertPublicHttpUrl, isBlockedIp } from "./safe-fetch";&lt;/p&gt;

&lt;p&gt;const mustBlock = [&lt;br&gt;
  "&lt;a href="http://127.0.0.1/" rel="noopener noreferrer"&gt;http://127.0.0.1/&lt;/a&gt;", "&lt;a href="http://2130706433/" rel="noopener noreferrer"&gt;http://2130706433/&lt;/a&gt;", "&lt;a href="http://0x7f000001/" rel="noopener noreferrer"&gt;http://0x7f000001/&lt;/a&gt;",&lt;br&gt;
  "&lt;a href="http://0177.0.0.1/" rel="noopener noreferrer"&gt;http://0177.0.0.1/&lt;/a&gt;", "http://[::1]/", "http://[::ffff:127.0.0.1]/",&lt;br&gt;
  "&lt;a href="http://169.254.169.254/" rel="noopener noreferrer"&gt;http://169.254.169.254/&lt;/a&gt;", "http://[fd00::1]/", "&lt;a href="http://LOCALHOST./" rel="noopener noreferrer"&gt;http://LOCALHOST./&lt;/a&gt;",&lt;br&gt;
  "&lt;a href="http://metadata.google.internal/" rel="noopener noreferrer"&gt;http://metadata.google.internal/&lt;/a&gt;", "&lt;a href="http://user:pw@example.com/" rel="noopener noreferrer"&gt;http://user:pw@example.com/&lt;/a&gt;",&lt;br&gt;
  "file:///etc/passwd",&lt;br&gt;
];&lt;/p&gt;

&lt;p&gt;for (const url of mustBlock) {&lt;br&gt;
  test(&lt;code&gt;blocks ${url}&lt;/code&gt;, () =&amp;gt; assert.throws(() =&amp;gt; assertPublicHttpUrl(url)));&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;test("blocks mapped metadata IP", () =&amp;gt; assert.ok(isBlockedIp("::ffff:169.254.169.254")));&lt;br&gt;
test("allows a public IP", () =&amp;gt; assert.ok(!isBlockedIp("93.184.216.34")));&lt;br&gt;
&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;Add one test for the redirect loop with a fake transport (a host that answers &lt;code&gt;302&lt;/code&gt; to &lt;code&gt;169.254.169.254&lt;/code&gt;), and one for the lookup hook with a fake DNS answer. Those two are the ones that catch real regressions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Running a regex on the raw input instead of on &lt;code&gt;new URL(input).hostname&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Calling &lt;code&gt;fetch()&lt;/code&gt; with the default &lt;code&gt;redirect: "follow"&lt;/code&gt; after validating only the first URL&lt;/li&gt;
&lt;li&gt;Resolving DNS, checking it, then letting the HTTP client resolve again&lt;/li&gt;
&lt;li&gt;Forgetting IPv6, especially IPv4-mapped forms like &lt;code&gt;::ffff:7f00:1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Allowing &lt;code&gt;file:&lt;/code&gt;, &lt;code&gt;gopher:&lt;/code&gt; or other schemes because "we only expect https"&lt;/li&gt;
&lt;li&gt;Treating "not an IP" as safe instead of failing closed&lt;/li&gt;
&lt;li&gt;Forwarding &lt;code&gt;Authorization&lt;/code&gt; or cookies to a redirect target on another host&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Parse with &lt;code&gt;new URL()&lt;/code&gt; first. It normalizes decimal, hex and octal IPv4 for you.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;net.BlockList&lt;/code&gt; for IP checks. It handles CIDR and IPv4-mapped IPv6.&lt;/li&gt;
&lt;li&gt;Follow redirects yourself and re-validate every hop.&lt;/li&gt;
&lt;li&gt;Check the DNS answer inside the &lt;code&gt;lookup&lt;/code&gt; hook, so the checked address is the connected address.&lt;/li&gt;
&lt;li&gt;Keep a payload test in CI, and allow only globally routable addresses rather than blocking a few famous ranges.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building this into a NestJS API, the same &lt;code&gt;safe-fetch&lt;/code&gt; module drops into a service. My &lt;a href="https://www.fabwaseem.com/blog/secure-nestjs-prisma-api-starter" rel="noopener noreferrer"&gt;secure NestJS + Prisma API starter&lt;/a&gt; covers the rest of the hardening, the &lt;a href="https://www.fabwaseem.com/blog/mcp-security" rel="noopener noreferrer"&gt;MCP security post&lt;/a&gt; covers the auth side of tool servers, and the &lt;a href="https://www.fabwaseem.com/blog/nestjs-server-sent-events" rel="noopener noreferrer"&gt;NestJS Server-Sent Events guide&lt;/a&gt; shows how Snagbox streams job progress after the URL passes these checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is checking the hostname enough to prevent SSRF in Node.js?
&lt;/h3&gt;

&lt;p&gt;No. A public hostname can resolve to a private IP, and a public URL can redirect to one. You need to check the parsed hostname, every redirect hop, and the IP address returned by DNS at connect time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does new URL() block decimal or hex IP addresses?
&lt;/h3&gt;

&lt;p&gt;It doesn't block them, but it normalizes them. new URL('&lt;a href="http://2130706433/').hostname" rel="noopener noreferrer"&gt;http://2130706433/').hostname&lt;/a&gt; is '127.0.0.1', so a check on url.hostname catches it. A regex on the raw string does not.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I stop fetch() from following redirects to internal IPs?
&lt;/h3&gt;

&lt;p&gt;Pass redirect: 'manual', read the Location header, resolve it against the current URL, validate the new URL, and only then request it. Repeat for each hop up to a small limit.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is DNS rebinding and how do I prevent it?
&lt;/h3&gt;

&lt;p&gt;An attacker's DNS server returns a public IP when you check and a private IP when you connect. Prevent it by validating addresses inside the lookup option of http.request or https.request, so the checked address is the connected address.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/ssrf-protection-nodejs" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>security</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Next.js use cache Explained: Cache Components, cacheLife and cacheTag in Next.js 16.4</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Fri, 23 Oct 2026 22:05:10 +0000</pubDate>
      <link>https://dev.to/fabwaseem/nextjs-use-cache-explained-cache-components-cachelife-and-cachetag-in-nextjs-164-33hc</link>
      <guid>https://dev.to/fabwaseem/nextjs-use-cache-explained-cache-components-cachelife-and-cachetag-in-nextjs-164-33hc</guid>
      <description>&lt;p&gt;&lt;em&gt;Cross-posted from my blog. The original, with any updates, lives on &lt;a href="https://www.fabwaseem.com/blog/nextjs-use-cache" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next.js &lt;code&gt;use cache&lt;/code&gt;&lt;/strong&gt; is a directive that marks an async function, component or whole file as cacheable, so its output is stored and reused for matching inputs. It is the core of &lt;strong&gt;Cache Components&lt;/strong&gt;, the caching model that Next.js 16.4 now turns on by default in &lt;code&gt;create-next-app&lt;/code&gt;. Add &lt;code&gt;'use cache'&lt;/code&gt;, set a lifetime with &lt;code&gt;cacheLife&lt;/code&gt;, label it with &lt;code&gt;cacheTag&lt;/code&gt;, and invalidate it with &lt;code&gt;updateTag&lt;/code&gt; or &lt;code&gt;revalidateTag&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This guide is written against &lt;strong&gt;Next.js 16.4.2 and React 19.3&lt;/strong&gt;, the current releases. I scaffolded a fresh app with &lt;code&gt;create-next-app@16.4.2&lt;/code&gt; (its generated &lt;code&gt;next.config.ts&lt;/code&gt; already contains &lt;code&gt;cacheComponents: true&lt;/code&gt; and &lt;code&gt;partialPrefetching: true&lt;/code&gt;), wrote every example below, then type-checked, linted, built and ran it with &lt;code&gt;next start&lt;/code&gt;. The build output, headers and timestamps shown are the real ones I got back. Framework behavior links to the official Next.js docs.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Security note:&lt;/strong&gt; the &lt;a href="https://nextjs.org/blog/nextjs-security-update-october-14-2026" rel="noopener noreferrer"&gt;October 14, 2026 Next.js security update&lt;/a&gt; fixed a critical remote code execution bug in &lt;code&gt;use cache&lt;/code&gt; functions that return multiple typed arrays. If you use Cache Components, run Next.js 16.4.1 or later before you ship anything from this post.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What does "use cache" do in Next.js?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;'use cache'&lt;/code&gt; tells Next.js that a function's return value depends only on its inputs, so it can be stored and reused. The first call runs the function and saves the output; later calls with the same arguments get the saved output until the entry revalidates. Cached output can also be baked into the page's prerendered static shell.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://nextjs.org/docs/app/api-reference/directives/use-cache" rel="noopener noreferrer"&gt;official &lt;code&gt;use cache&lt;/code&gt; reference&lt;/a&gt; lists what goes into a cache key:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build ID&lt;/strong&gt;: every deploy gets new keys, so caches do not survive a deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Function ID&lt;/strong&gt;: a secure hash of where the function lives and its signature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serializable arguments&lt;/strong&gt;: function arguments or component props.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Closed-over variables&lt;/strong&gt;: values captured from an outer scope become part of the key.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That makes it different from the old App Router model, where &lt;code&gt;fetch&lt;/code&gt; calls and route segments were cached implicitly. With Cache Components, &lt;strong&gt;nothing is cached unless you say so&lt;/strong&gt;, and anything you do cache is explicit at the call site.&lt;/p&gt;

&lt;h3&gt;
  
  
  Quick definitions
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cache Components&lt;/td&gt;
&lt;td&gt;The Next.js 16 programming model behind &lt;code&gt;cacheComponents: true&lt;/code&gt;: explicit &lt;code&gt;use cache&lt;/code&gt;, Partial Prerendering and Suspense streaming (&lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;'use cache'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Directive that caches a function, component or file's exports, shared across users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cacheLife&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Sets how long an entry is fresh (&lt;code&gt;stale&lt;/code&gt;, &lt;code&gt;revalidate&lt;/code&gt;, &lt;code&gt;expire&lt;/code&gt;) (&lt;a href="https://nextjs.org/docs/app/api-reference/functions/cacheLife" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cacheTag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Labels an entry so you can invalidate it on demand (&lt;a href="https://nextjs.org/docs/app/api-reference/functions/cacheTag" rel="noopener noreferrer"&gt;docs&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;updateTag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Expires a tag immediately from a Server Action; the next read waits for fresh data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;revalidateTag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Marks a tag stale from a Server Function or Route Handler; serves stale while it refreshes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static shell&lt;/td&gt;
&lt;td&gt;The prerendered HTML of a route, including cached output and Suspense fallbacks&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  TL;DR: Next.js use cache in five lines
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You want to...&lt;/th&gt;
&lt;th&gt;Do this&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cache a database query or API call for everyone&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'use cache'&lt;/code&gt; + &lt;code&gt;cacheLife('hours')&lt;/code&gt; + &lt;code&gt;cacheTag('x')&lt;/code&gt; in the data function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Show a user's change right after they submit a form&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;updateTag('x')&lt;/code&gt; inside the Server Action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refresh after a CMS or Stripe webhook&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;revalidateTag('x', 'max')&lt;/code&gt; in a Route Handler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read cookies or headers&lt;/td&gt;
&lt;td&gt;Read them outside the cached scope and pass values as arguments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache something that must read cookies inside&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'use cache: private'&lt;/code&gt; (per browser only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Share a cache across many server instances&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'use cache: remote'&lt;/code&gt; with a cache handler&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Is Cache Components enabled by default in Next.js 16.4?
&lt;/h2&gt;

&lt;p&gt;Yes, for new apps. Since &lt;a href="https://nextjs.org/blog/next-16-4" rel="noopener noreferrer"&gt;Next.js 16.4&lt;/a&gt;, released on October 6, 2026, every app created with &lt;code&gt;create-next-app&lt;/code&gt; has Cache Components enabled, and the Next.js team says it will become the default for all apps in Next.js 17. Existing apps keep the old model until you add &lt;code&gt;cacheComponents: true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is what &lt;code&gt;create-next-app@16.4.2&lt;/code&gt; generated for me, unedited apart from the Tailwind loader lines:&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;// next.config.ts&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;NextConfig&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&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;nextConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;cacheComponents&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;partialPrefetching&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;next build&lt;/code&gt; confirms it at the top of the log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;✓ Running next.config.ts took 21ms
- Cache Components enabled
- Partial Prefetching enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do you use "use cache" in Next.js? (step by step)
&lt;/h2&gt;

&lt;p&gt;Here is the full working pattern from my test app: a product list that every visitor shares, a greeting that reads a cookie, and a form that adds a product.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: cache the data function
&lt;/h3&gt;

&lt;p&gt;Put the directive at the top of the async function body, then set an explicit lifetime and a tag:&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/products.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;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="s1"&gt;next/cache&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;queryProducts&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;./db&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// Shared, cached data function: one entry for every visitor.&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;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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;products&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;queryProducts&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="nx"&gt;products&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cachedAt&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;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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;cachedAt&lt;/code&gt; is there only to prove caching in the tests below. Because &lt;code&gt;new Date()&lt;/code&gt; runs inside the cached scope, its value is stored with the entry, and the route still prerenders.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: render cached and request-time parts on one page
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/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;Suspense&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;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="s1"&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;getProducts&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/products&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;addProduct&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;./actions&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Home&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;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Store&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Request-time hole: reads a cookie, so it streams in */&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="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Loading greeting...&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;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;Greeting&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;Suspense&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 the 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;ProductList&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;form&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;addProduct&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="nt"&gt;input&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Name"&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;input&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"price"&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"number"&lt;/span&gt; &lt;span class="na"&gt;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Price"&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;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Add&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="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;form&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;main&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;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Greeting&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;name&lt;/span&gt; &lt;span class="o"&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;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="s1"&gt;name&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="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;guest&lt;/span&gt;&lt;span class="dl"&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;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Hi, &lt;span class="si"&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;p&lt;/span&gt;&lt;span class="p"&gt;&amp;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;ProductList&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;products&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cachedAt&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;getProducts&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;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;products&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;p&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;p&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;p&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="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;cached at &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cachedAt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: invalidate from a Server Action with updateTag
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/actions.ts&lt;/span&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="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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;insertProduct&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/db&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;addProduct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FormData&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;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&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;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&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;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&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;price&lt;/span&gt;&lt;span class="dl"&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;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&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;await&lt;/span&gt; &lt;span class="nf"&gt;insertProduct&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="c1"&gt;// Expire the tag now: the next read waits for fresh data&lt;/span&gt;
  &lt;span class="c1"&gt;// (read-your-own-writes for the user who submitted the form).&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a real app, authenticate and authorize inside the action first. My &lt;a href="https://www.fabwaseem.com/blog/server-actions-vs-route-handlers" rel="noopener noreferrer"&gt;Server Actions vs Route Handlers guide&lt;/a&gt; covers why every action is a public endpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: invalidate from a webhook with revalidateTag
&lt;/h3&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/revalidate/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="c1"&gt;// Called by a CMS or admin webhook after products change.&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;request&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="k"&gt;if &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="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;authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &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;REVALIDATE_SECRET&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;return&lt;/span&gt; &lt;span class="nx"&gt;Response&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// Stale-while-revalidate: visitors keep getting the old list&lt;/span&gt;
  &lt;span class="c1"&gt;// while the next request refreshes it in the background.&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&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="na"&gt;revalidated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 5: build and read the route table
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Route &lt;span class="o"&gt;(&lt;/span&gt;app&lt;span class="o"&gt;)&lt;/span&gt;          Revalidate  Expire
┌ ◐ /                        1h      1d
├ ○ /_not-found
├ ◐ /account
├ ○ /api/products            1h      1d
└ ƒ /api/revalidate

○  &lt;span class="o"&gt;(&lt;/span&gt;Static&lt;span class="o"&gt;)&lt;/span&gt;             prerendered as static content
◐  &lt;span class="o"&gt;(&lt;/span&gt;Partial Prerender&lt;span class="o"&gt;)&lt;/span&gt;  prerendered as static HTML with dynamic server-streamed content
ƒ  &lt;span class="o"&gt;(&lt;/span&gt;Dynamic&lt;span class="o"&gt;)&lt;/span&gt;            server-rendered on demand
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The home page is &lt;strong&gt;◐ Partial Prerender&lt;/strong&gt;: the product list is in the static shell, and the cookie greeting streams in. The &lt;code&gt;1h&lt;/code&gt; / &lt;code&gt;1d&lt;/code&gt; columns come straight from the &lt;code&gt;hours&lt;/code&gt; profile. The webhook route is dynamic, as it should be.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ferlv3m02b71sahpbt7b0.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ferlv3m02b71sahpbt7b0.jpg" alt="How Cache Components render one page: static markup and use cache output go into the static shell, while a Suspense boundary that reads cookies streams at request time" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Does it actually cache? What I measured with next start
&lt;/h2&gt;

&lt;p&gt;These are the real responses from &lt;code&gt;next start&lt;/code&gt; on the test app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two requests, one entry.&lt;/strong&gt; A &lt;code&gt;GET&lt;/code&gt; Route Handler that calls &lt;code&gt;getProducts()&lt;/code&gt; returned the same timestamp twice. It was filled during &lt;code&gt;next build&lt;/code&gt; and reused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:3141/api/products
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"count"&lt;/span&gt;:2,&lt;span class="s2"&gt;"cachedAt"&lt;/span&gt;:&lt;span class="s2"&gt;"2026-10-17T13:01:48.233Z"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:3141/api/products
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"count"&lt;/span&gt;:2,&lt;span class="s2"&gt;"cachedAt"&lt;/span&gt;:&lt;span class="s2"&gt;"2026-10-17T13:01:48.233Z"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Shared cache, personal hole.&lt;/strong&gt; Sending &lt;code&gt;cookie: name=Waseem&lt;/code&gt; changed the greeting to &lt;code&gt;Hi, Waseem&lt;/code&gt; while the product list kept the same &lt;code&gt;cached at&lt;/code&gt; value. The response headers show how Next.js treats the page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;x-nextjs-stale-time: 300
x-nextjs-prerender: 1
x-nextjs-postponed: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;x-nextjs-stale-time: 300&lt;/code&gt; is the 5-minute client &lt;code&gt;stale&lt;/code&gt; value from the &lt;code&gt;hours&lt;/code&gt; profile, and &lt;code&gt;x-nextjs-postponed: 1&lt;/code&gt; means part of the page was deferred to request time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;revalidateTag is stale-while-revalidate.&lt;/strong&gt; Without the bearer token the webhook returned &lt;code&gt;401&lt;/code&gt;. With it, the first request after revalidation still got the old entry, and the next one got a fresh one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'authorization: Bearer s3cret'&lt;/span&gt; localhost:3141/api/revalidate
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"revalidated"&lt;/span&gt;:true&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:3141/api/products
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"count"&lt;/span&gt;:2,&lt;span class="s2"&gt;"cachedAt"&lt;/span&gt;:&lt;span class="s2"&gt;"2026-10-17T13:01:48.233Z"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;   &lt;span class="c"&gt;# stale, refresh starts&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; localhost:3141/api/products
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"count"&lt;/span&gt;:2,&lt;span class="s2"&gt;"cachedAt"&lt;/span&gt;:&lt;span class="s2"&gt;"2026-10-17T13:02:01.283Z"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;   &lt;span class="c"&gt;# fresh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;updateTag is read-your-own-writes.&lt;/strong&gt; In a headless Chrome test, submitting the form with "Webcam, 59" re-rendered the list with three items and a new &lt;code&gt;cached at&lt;/code&gt; time in the same navigation. A second browser that opened the page afterwards saw the same three items and the same new timestamp, so the shared entry was replaced, not forked per user.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the cacheLife profiles?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;cacheLife&lt;/code&gt; sets three values: &lt;code&gt;stale&lt;/code&gt; (how long the browser may reuse it without asking), &lt;code&gt;revalidate&lt;/code&gt; (after which the server refreshes it in the background) and &lt;code&gt;expire&lt;/code&gt; (after which a request must wait for fresh data). These are the built-in presets from the &lt;a href="https://nextjs.org/docs/app/api-reference/functions/cacheLife" rel="noopener noreferrer"&gt;cacheLife reference&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Profile&lt;/th&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;&lt;code&gt;stale&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;revalidate&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;expire&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;default&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Standard content&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;15 minutes&lt;/td&gt;
&lt;td&gt;never&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;seconds&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Real-time data&lt;/td&gt;
&lt;td&gt;30 seconds&lt;/td&gt;
&lt;td&gt;1 second&lt;/td&gt;
&lt;td&gt;1 minute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;minutes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Frequently updated content&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;1 minute&lt;/td&gt;
&lt;td&gt;1 hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hours&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Updated several times a day&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;1 hour&lt;/td&gt;
&lt;td&gt;1 day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;days&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Updated daily&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;1 day&lt;/td&gt;
&lt;td&gt;1 week&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;weeks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Updated weekly&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;1 week&lt;/td&gt;
&lt;td&gt;30 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rarely changes&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;30 days&lt;/td&gt;
&lt;td&gt;1 year&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three rules worth remembering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Always set &lt;code&gt;cacheLife&lt;/code&gt; explicitly.&lt;/strong&gt; Without it the &lt;code&gt;default&lt;/code&gt; profile applies, and nesting a short-lived cache inside a scope with no explicit &lt;code&gt;cacheLife&lt;/code&gt; fails the build during prerendering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The browser keeps cached output for at least 30 seconds.&lt;/strong&gt; The client router enforces a minimum 30-second stale time regardless of your profile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can define custom profiles&lt;/strong&gt; in &lt;code&gt;next.config.ts&lt;/code&gt; under &lt;code&gt;cacheLife&lt;/code&gt;, or pass an inline object such as &lt;code&gt;cacheLife({ stale: 60, revalidate: 600, expire: 3600 })&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  updateTag vs revalidateTag: which should you use?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;updateTag&lt;/code&gt; inside a Server Action when the user who made a change must see it on the very next render. Use &lt;code&gt;revalidateTag(tag, 'max')&lt;/code&gt; from a Route Handler or webhook when a short window of stale data is fine. &lt;code&gt;updateTag&lt;/code&gt; blocks the next read until fresh data loads; &lt;code&gt;revalidateTag&lt;/code&gt; serves stale content while it refreshes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;updateTag(tag)&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;revalidateTag(tag, 'max')&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where it runs&lt;/td&gt;
&lt;td&gt;Server Actions only&lt;/td&gt;
&lt;td&gt;Server Functions and Route Handlers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next read&lt;/td&gt;
&lt;td&gt;Waits for fresh data&lt;/td&gt;
&lt;td&gt;Gets stale data, triggers a refresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Forms, settings, carts: read-your-own-writes&lt;/td&gt;
&lt;td&gt;CMS publish, Stripe and GitHub webhooks, admin jobs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In a Route Handler&lt;/td&gt;
&lt;td&gt;Throws&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;a href="https://nextjs.org/docs/app/api-reference/functions/revalidateTag" rel="noopener noreferrer"&gt;revalidateTag reference&lt;/a&gt; recommends the &lt;code&gt;'max'&lt;/code&gt; profile and marks the single-argument form as deprecated; it behaves like &lt;code&gt;{ expire: 0 }&lt;/code&gt;, a blocking cache miss. Tags are case-sensitive and capped at 256 characters, and a longer tag is silently never assigned. Revalidation is triggered by the next request, not by the call itself, so pages refresh as they are visited.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fabqe8gde4whyfyuqd35t.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fabqe8gde4whyfyuqd35t.jpg" alt="Decision chart: use cache for shared data, use cache remote for shared data across many instances, use cache private for data that must read cookies; then updateTag for Server Actions, revalidateTag with max for webhooks, and cacheLife for time-based refresh" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  use cache vs use cache: private vs use cache: remote
&lt;/h2&gt;

&lt;p&gt;Next.js has three caching directives. Plain &lt;code&gt;'use cache'&lt;/code&gt; covers most apps. The other two solve specific problems, and the &lt;a href="https://nextjs.org/docs/app/api-reference/directives/use-cache-remote" rel="noopener noreferrer"&gt;use cache: remote reference&lt;/a&gt; compares them:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;&lt;code&gt;'use cache'&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;'use cache: remote'&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;'use cache: private'&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server-side storage&lt;/td&gt;
&lt;td&gt;In memory or a cache handler&lt;/td&gt;
&lt;td&gt;Remote cache handler&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared between users&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No, per browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can call &lt;code&gt;cookies()&lt;/code&gt; / &lt;code&gt;headers()&lt;/code&gt; inside&lt;/td&gt;
&lt;td&gt;No, pass values as arguments&lt;/td&gt;
&lt;td&gt;No, pass values as arguments&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra cost&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Storage and network&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Survives a deploy&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  When should you use "use cache: private"?
&lt;/h3&gt;

&lt;p&gt;Use it only when a function must read cookies or headers itself and you cannot refactor it to take those values as arguments, or when compliance rules forbid storing request data in a server cache. It runs at request time, is excluded from the static shell and is cached only in the browser. This is the version from my test app:&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/account/page.tsx (excerpt)&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="s1"&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;cacheLife&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="c1"&gt;// Reads cookies directly. Cached per browser only, never in a shared server cache.&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;getPreferences&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: private&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="na"&gt;stale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&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;currency&lt;/span&gt; &lt;span class="o"&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;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="s1"&gt;currency&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="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;currency&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;With a &lt;code&gt;currency=PKR&lt;/code&gt; cookie the page rendered &lt;code&gt;Currency: PKR&lt;/code&gt;, and the route still built as ◐ because the component sits inside &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  When does "use cache: remote" make sense?
&lt;/h3&gt;

&lt;p&gt;When request-time content gets low hit rates from per-instance memory: serverless functions, many replicas, or an expensive upstream such as a slow CMS. It stores entries in a shared handler like Redis. Skip it when operations already take under about 50 ms, when keys are mostly unique per request, or when data changes every few seconds; the docs list those as cases where remote caching does not pay off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does "use cache" work when self-hosting with Docker or Coolify?
&lt;/h2&gt;

&lt;p&gt;Yes. The official platform table lists both the Node.js server and Docker as supported, and static export as not supported. When self-hosted, the default in-memory cache persists across requests in the same process, which is exactly what the &lt;code&gt;next start&lt;/code&gt; tests above show. Size it with &lt;code&gt;cacheMaxMemorySize&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two things change once you run more than one container:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Each replica has its own memory cache.&lt;/strong&gt; Two instances can serve different &lt;code&gt;cachedAt&lt;/code&gt; values until both refresh. Configure a shared &lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheHandlers" rel="noopener noreferrer"&gt;&lt;code&gt;cacheHandlers&lt;/code&gt;&lt;/a&gt; entry (for example Redis) if that matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every deploy starts cold.&lt;/strong&gt; The cache key includes the build ID, so &lt;code&gt;use cache&lt;/code&gt; entries do not carry over. For data that must survive deploys, the docs still point to &lt;code&gt;unstable_cache&lt;/code&gt; or the &lt;code&gt;fetch&lt;/code&gt; cache.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are deploying this way, my &lt;a href="https://www.fabwaseem.com/blog/self-host-nextjs-with-coolify" rel="noopener noreferrer"&gt;self-hosting Next.js with Coolify guide&lt;/a&gt; and &lt;a href="https://www.fabwaseem.com/blog/nextjs-dockerfile" rel="noopener noreferrer"&gt;production Next.js Dockerfile&lt;/a&gt; cover the container side.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you migrate an existing app to Cache Components?
&lt;/h2&gt;

&lt;p&gt;Enable &lt;code&gt;cacheComponents: true&lt;/code&gt;, then replace each route segment config the build complains about. The &lt;a href="https://nextjs.org/docs/app/guides/migrating-to-cache-components" rel="noopener noreferrer"&gt;migration guide&lt;/a&gt; says routes that still export &lt;code&gt;dynamic&lt;/code&gt;, &lt;code&gt;revalidate&lt;/code&gt; or &lt;code&gt;fetchCache&lt;/code&gt; will error once the flag is on. Most of the work is mechanical:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Before (old App Router)&lt;/th&gt;
&lt;th&gt;After (Cache Components)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;export const revalidate = 3600&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'use cache'&lt;/code&gt; + &lt;code&gt;cacheLife('hours')&lt;/code&gt; around the data or UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;export const revalidate = 0&lt;/code&gt; / &lt;code&gt;force-dynamic&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Remove it; uncached data already runs at request time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;export const fetchCache = 'force-cache'&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wrap the data in &lt;code&gt;'use cache'&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unstable_cache(fn, keys, { tags, revalidate })&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Optional: &lt;code&gt;'use cache'&lt;/code&gt; + &lt;code&gt;cacheTag&lt;/code&gt; + &lt;code&gt;cacheLife&lt;/code&gt;; the old API keeps working&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fetch(url, { next: { tags } })&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cacheTag&lt;/code&gt; inside a &lt;code&gt;'use cache'&lt;/code&gt; function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;revalidateTag('x')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;updateTag('x')&lt;/code&gt; in actions, &lt;code&gt;revalidateTag('x', 'max')&lt;/code&gt; elsewhere&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a large app, adopt it incrementally:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Turn on the flag and migrate the segment configs above.&lt;/li&gt;
&lt;li&gt;Opt out routes that are not ready with &lt;code&gt;export const instant = false&lt;/code&gt;, or run the codemod across the app: &lt;code&gt;npx @next/codemod@canary cache-components-instant-false ./app&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Fix synchronous IO such as &lt;code&gt;new Date()&lt;/code&gt;, &lt;code&gt;Math.random()&lt;/code&gt; and &lt;code&gt;crypto.randomUUID()&lt;/code&gt; in the prerendered shell; &lt;code&gt;instant = false&lt;/code&gt; does not clear those errors. Move the call into a cached scope, a Client Component, or behind &lt;code&gt;connection()&lt;/code&gt; inside &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Remove &lt;code&gt;instant = false&lt;/code&gt; one route at a time and resolve the dev overlay insights.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you use a coding agent, Next.js ships a skill for this: &lt;code&gt;npx skills add vercel/next.js --skill next-cache-components-adoption&lt;/code&gt;, then ask the agent to adopt Cache Components with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can't you do inside "use cache"?
&lt;/h2&gt;

&lt;p&gt;Cached functions run in an isolated environment, and four limits catch most people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No request APIs.&lt;/strong&gt; &lt;code&gt;cookies()&lt;/code&gt;, &lt;code&gt;headers()&lt;/code&gt; and &lt;code&gt;searchParams&lt;/code&gt; are not allowed, even in a helper the cached function calls. On dynamic routes this can pass &lt;code&gt;next build&lt;/code&gt; and fail under &lt;code&gt;next start&lt;/code&gt; with the &lt;code&gt;next-request-in-use-cache&lt;/code&gt; error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serializable inputs and outputs only.&lt;/strong&gt; Arguments follow Server Component serialization, return values follow Client Component serialization. Class instances, symbols, &lt;code&gt;URL&lt;/code&gt; objects and functions (except as pass-through) are rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;React.cache&lt;/code&gt; does not cross the boundary.&lt;/strong&gt; Values stored with &lt;code&gt;React.cache&lt;/code&gt; outside a cached scope are not visible inside it. Pass data as arguments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Promises of runtime data hang the build.&lt;/strong&gt; Passing a &lt;code&gt;cookies()&lt;/code&gt; promise or uncached data into a cached function makes prerendering wait, and the build times out after 50 seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For debugging, run &lt;code&gt;NEXT_PRIVATE_DEBUG_CACHE=1 npm run start&lt;/code&gt; for verbose cache logs. In development, logs from cached functions are replayed with a &lt;code&gt;Cache&lt;/code&gt; prefix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common "use cache" mistakes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mistake&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Calling &lt;code&gt;cookies()&lt;/code&gt; inside &lt;code&gt;'use cache'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;next-request-in-use-cache&lt;/code&gt; error&lt;/td&gt;
&lt;td&gt;Read it outside and pass the value in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caching per-user data with plain &lt;code&gt;'use cache'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Users can see each other's data if the key misses the user ID&lt;/td&gt;
&lt;td&gt;Add the user ID as an argument, or use &lt;code&gt;'use cache: private'&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No &lt;code&gt;cacheLife&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;default&lt;/code&gt; profile applies; nested short caches can fail the build&lt;/td&gt;
&lt;td&gt;Set a profile in every cached scope&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;updateTag&lt;/code&gt; in a Route Handler&lt;/td&gt;
&lt;td&gt;Throws at runtime&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;revalidateTag(tag, 'max')&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;revalidateTag('x')&lt;/code&gt; with one argument&lt;/td&gt;
&lt;td&gt;Deprecated, blocking miss&lt;/td&gt;
&lt;td&gt;Pass &lt;code&gt;'max'&lt;/code&gt; or migrate to &lt;code&gt;updateTag&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expecting caches to survive deploys&lt;/td&gt;
&lt;td&gt;Cold cache after each deploy&lt;/td&gt;
&lt;td&gt;Accept it, or use &lt;code&gt;unstable_cache&lt;/code&gt; / &lt;code&gt;fetch&lt;/code&gt; cache for that data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Several replicas, default handler&lt;/td&gt;
&lt;td&gt;Replicas disagree for a while&lt;/td&gt;
&lt;td&gt;Shared &lt;code&gt;cacheHandlers&lt;/code&gt; or &lt;code&gt;'use cache: remote'&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Running Next.js below 16.4.1&lt;/td&gt;
&lt;td&gt;Exposed to the October 2026 critical advisories&lt;/td&gt;
&lt;td&gt;Upgrade now&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; Next.js 16.4.1 patched a critical RCE in &lt;code&gt;use cache&lt;/code&gt; functions that return typed arrays; see the &lt;a href="https://www.fabwaseem.com/blog/nextjs-security-update-october-2026" rel="noopener noreferrer"&gt;Next.js security update October 2026&lt;/a&gt;. To let an AI agent do the migration for you, install the official &lt;code&gt;next-cache-components-adoption&lt;/code&gt; skill as shown in &lt;a href="https://www.fabwaseem.com/blog/agent-skills" rel="noopener noreferrer"&gt;Agent Skills: how to write a SKILL.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; I reused this demo app to benchmark the &lt;a href="https://www.fabwaseem.com/blog/typescript-7" rel="noopener noreferrer"&gt;TypeScript 7 upgrade&lt;/a&gt;: the &lt;code&gt;next build&lt;/code&gt; type-check step dropped from 1,368 ms to 231 ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; my &lt;a href="https://www.fabwaseem.com/blog/react-view-transitions" rel="noopener noreferrer"&gt;React View Transitions guide&lt;/a&gt; uses Cache Components with &lt;code&gt;connection()&lt;/code&gt; so a Suspense fallback streams in and animates its reveal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; for a side-by-side with a non-Next.js framework, see &lt;a href="https://www.fabwaseem.com/blog/tanstack-start-vs-nextjs" rel="noopener noreferrer"&gt;TanStack Start vs Next.js&lt;/a&gt;, where I benchmarked the same app on both for server throughput, client JS, hydration and build time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;'use cache'&lt;/code&gt; caches an async function, component or file by its inputs; with Cache Components nothing else is cached implicitly.&lt;/li&gt;
&lt;li&gt;New Next.js 16.4 apps have Cache Components and Partial Prefetching on by default, and it becomes the default for all apps in Next.js 17.&lt;/li&gt;
&lt;li&gt;Pair every cached scope with &lt;code&gt;cacheLife&lt;/code&gt; and &lt;code&gt;cacheTag&lt;/code&gt;, then invalidate with &lt;code&gt;updateTag&lt;/code&gt; in Server Actions or &lt;code&gt;revalidateTag(tag, 'max')&lt;/code&gt; elsewhere.&lt;/li&gt;
&lt;li&gt;Request APIs stay outside cached scopes; pass their values in as arguments, or reach for &lt;code&gt;'use cache: private'&lt;/code&gt; when you truly cannot.&lt;/li&gt;
&lt;li&gt;Self-hosted &lt;code&gt;next start&lt;/code&gt; keeps entries in memory across requests, but not across deploys or between replicas.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What does 'use cache' do in Next.js?
&lt;/h3&gt;

&lt;p&gt;'use cache' marks an async function, component or file's exports as cacheable. Next.js runs it once for a given set of serializable inputs, stores the output, and reuses it until the entry revalidates through cacheLife or a cacheTag invalidation. Cached output can also be included in the route's prerendered static shell.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Cache Components enabled by default in Next.js 16.4?
&lt;/h3&gt;

&lt;p&gt;Yes for new apps. Since Next.js 16.4, create-next-app generates a next.config.ts with cacheComponents: true and partialPrefetching: true. Existing apps keep the previous model until you enable the flag, and the Next.js team plans to make Cache Components the default for all apps in Next.js 17.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the difference between updateTag and revalidateTag?
&lt;/h3&gt;

&lt;p&gt;updateTag runs only in Server Actions and expires a tag immediately, so the next read waits for fresh data and the user sees their own change. revalidateTag runs in Server Functions and Route Handlers; with the recommended 'max' profile it marks the tag stale and serves stale content while the next request refreshes it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use cookies() inside 'use cache'?
&lt;/h3&gt;

&lt;p&gt;No. cookies(), headers() and searchParams are not allowed inside a 'use cache' scope or any helper it calls, and doing so throws the next-request-in-use-cache error. Read the value outside the cached function and pass it in as an argument. If you cannot refactor, 'use cache: private' allows request APIs but caches only in the browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does 'use cache' replace unstable_cache?
&lt;/h3&gt;

&lt;p&gt;For most code, yes, but you do not have to migrate. unstable_cache keeps working with Cache Components. One difference matters: unstable_cache and the fetch cache persist across deployments, while 'use cache' entries are keyed by build ID and start cold after every deploy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does 'use cache' work when self-hosting Next.js with Docker?
&lt;/h3&gt;

&lt;p&gt;Yes. Node.js servers and Docker containers are supported; static export is not. Self-hosted, the default in-memory cache persists across requests in the same process, sized by cacheMaxMemorySize. With several replicas each has its own cache, so configure a shared cacheHandlers entry such as Redis if they must agree.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/nextjs-use-cache" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>Next.js Security Update October 2026: Patch the use cache RCE, SVG sharp RCE and Server Actions DoS</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Fri, 23 Oct 2026 19:04:06 +0000</pubDate>
      <link>https://dev.to/fabwaseem/nextjs-security-update-october-2026-patch-the-use-cache-rce-svg-sharp-rce-and-server-actions-dos-5777</link>
      <guid>https://dev.to/fabwaseem/nextjs-security-update-october-2026-patch-the-use-cache-rce-svg-sharp-rce-and-server-actions-dos-5777</guid>
      <description>&lt;p&gt;The &lt;strong&gt;Next.js security update&lt;/strong&gt; of October 14, 2026 fixes three vulnerabilities: two &lt;strong&gt;critical&lt;/strong&gt; unauthenticated remote code execution (RCE) bugs, one in &lt;code&gt;use cache&lt;/code&gt; and one in SVG image optimization through &lt;code&gt;sharp&lt;/code&gt;, and a &lt;strong&gt;high&lt;/strong&gt; severity denial of service in Server Actions. Upgrade to &lt;strong&gt;Next.js 16.4.1 or later&lt;/strong&gt; on 16.x, or &lt;strong&gt;15.5.28&lt;/strong&gt; on 15.x, make sure &lt;code&gt;sharp&lt;/code&gt; is &lt;strong&gt;0.35.5 or later&lt;/strong&gt;, then rebuild and redeploy.&lt;/p&gt;

&lt;p&gt;The patch is easy to apply and easy to get wrong when you self-host. I reproduced the upgrade on real installs and found that &lt;strong&gt;upgrading Next.js 15 to 15.5.28 left the vulnerable &lt;code&gt;sharp&lt;/code&gt; 0.34.5 in place&lt;/strong&gt;, while &lt;code&gt;npm audit&lt;/code&gt; on an unpatched &lt;code&gt;next@16.4.0&lt;/code&gt; reported &lt;strong&gt;0 vulnerabilities&lt;/strong&gt;. This guide shows how to check whether you are affected, a script that checks for you, and the exact steps to patch a Docker or Coolify deployment. Everything is cited to the official advisories.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did the October 2026 Next.js security update fix?
&lt;/h2&gt;

&lt;p&gt;It fixed three advisories published on October 14, 2026, all patched in Next.js 16.4.1 (Active LTS) and 15.5.28 (Maintenance LTS), according to the &lt;a href="https://nextjs.org/blog/nextjs-security-update-october-14-2026" rel="noopener noreferrer"&gt;official Next.js security update&lt;/a&gt;. Two allow unauthenticated remote code execution with a CVSS 4.0 score of 9.5; the third lets crafted requests pin a server's CPU.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Advisory&lt;/th&gt;
&lt;th&gt;Severity&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Affected &lt;code&gt;next&lt;/code&gt; versions&lt;/th&gt;
&lt;th&gt;Fixed in&lt;/th&gt;
&lt;th&gt;Workaround&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/vercel/next.js/security/advisories/GHSA-673q-mjm6-r5xr" rel="noopener noreferrer"&gt;GHSA-673q-mjm6-r5xr&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Critical, 9.5&lt;/td&gt;
&lt;td&gt;RCE in &lt;code&gt;use cache&lt;/code&gt; functions that return multiple typed arrays&lt;/td&gt;
&lt;td&gt;16.0.0 to 16.4.0&lt;/td&gt;
&lt;td&gt;16.4.1&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/vercel/next.js/security/advisories/GHSA-2464-fhr4-w9xw" rel="noopener noreferrer"&gt;CVE-2026-96889 / GHSA-2464-fhr4-w9xw&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Critical, 9.5&lt;/td&gt;
&lt;td&gt;RCE in the Image Optimization API with SVG, via &lt;code&gt;sharp&lt;/code&gt;'s librsvg, on glibc Linux&lt;/td&gt;
&lt;td&gt;10.0.0 to 15.5.27, 16.0.0 to 16.4.0&lt;/td&gt;
&lt;td&gt;15.5.28, 16.4.1&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sharp&lt;/code&gt; 0.35.5 or later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/vercel/next.js/security/advisories/GHSA-p3m5-59mp-jv5r" rel="noopener noreferrer"&gt;GHSA-p3m5-59mp-jv5r&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;High, 8.2&lt;/td&gt;
&lt;td&gt;DoS: crafted requests cause excessive CPU in App Router apps with Server Actions&lt;/td&gt;
&lt;td&gt;13.0.0 to 15.5.27, 16.0.0 to 16.4.0&lt;/td&gt;
&lt;td&gt;15.5.28, 16.4.1&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two React Server Components bugs also have upstream React advisories: &lt;a href="https://github.com/facebook/react/security/advisories/GHSA-xqc2-xf4c-x3h3" rel="noopener noreferrer"&gt;GHSA-xqc2-xf4c-x3h3&lt;/a&gt; for the typed-array RCE and &lt;a href="https://github.com/facebook/react/security/advisories/GHSA-h3gv-c5rc-p453" rel="noopener noreferrer"&gt;GHSA-h3gv-c5rc-p453&lt;/a&gt; for the DoS. Next.js ships its own compiled copy of &lt;code&gt;react-server-dom-webpack&lt;/code&gt; and &lt;code&gt;react-server-dom-turbopack&lt;/code&gt; inside the &lt;code&gt;next&lt;/code&gt; package (I checked &lt;code&gt;node_modules/next/dist/compiled&lt;/code&gt;), so &lt;strong&gt;upgrading &lt;code&gt;next&lt;/code&gt; is what fixes a Next.js app&lt;/strong&gt;, not bumping &lt;code&gt;react&lt;/code&gt; alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Quick definitions
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RCE&lt;/td&gt;
&lt;td&gt;Remote code execution: an attacker can run code on your server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DoS&lt;/td&gt;
&lt;td&gt;Denial of service: the server stops answering legitimate requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GHSA&lt;/td&gt;
&lt;td&gt;A GitHub Security Advisory ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sharp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The image library Next.js uses for Image Optimization; it bundles libvips and librsvg&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;glibc&lt;/td&gt;
&lt;td&gt;The C library used by Debian, Ubuntu and &lt;code&gt;node:*-slim&lt;/code&gt; images (Alpine uses musl instead)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Active / Maintenance LTS&lt;/td&gt;
&lt;td&gt;Next.js 16.4.x and 15.5.x, the two release lines that receive security fixes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  TL;DR: how to patch Next.js now
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Upgrade: &lt;code&gt;npm install next@16.4.2&lt;/code&gt; on 16.x (16.4.1 is the security release; 16.4.2 adds bug fixes) or &lt;code&gt;npm install next@15.5.28&lt;/code&gt; on 15.x.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;sharp&lt;/code&gt;: run &lt;code&gt;npm ls next sharp&lt;/code&gt; and confirm &lt;code&gt;sharp@0.35.5&lt;/code&gt; or later. On 15.x, run &lt;code&gt;npm update sharp&lt;/code&gt; if it still shows 0.34.x.&lt;/li&gt;
&lt;li&gt;Commit the lockfile, rebuild the image from scratch and redeploy.&lt;/li&gt;
&lt;li&gt;Verify the running container, not just your laptop.&lt;/li&gt;
&lt;/ol&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1uty02w9qgy68znn82sd.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1uty02w9qgy68znn82sd.jpg" alt="Decision chart: check the installed next version, then the three advisories with their affected versions, exposure conditions and workarounds, and the fix of next 16.4.1 or 15.5.28 with sharp 0.35.5" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Is my Next.js app affected?
&lt;/h2&gt;

&lt;p&gt;You are affected if your installed &lt;code&gt;next&lt;/code&gt; is 16.0.0 through 16.4.0, or 15.5.27 and earlier, or if the &lt;code&gt;sharp&lt;/code&gt; copy Next.js loads is older than 0.35.5. Which bugs reach you depends on features: &lt;code&gt;use cache&lt;/code&gt; needs Cache Components, the DoS needs at least one Server Action, and the SVG bug needs glibc Linux.&lt;/p&gt;

&lt;p&gt;Check the versions first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;ls &lt;/span&gt;next sharp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then match your app against each advisory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;use cache&lt;/code&gt; RCE (GHSA-673q-mjm6-r5xr).&lt;/strong&gt; Only Next.js 16 apps using the App Router with Cache Components can be exposed, and the trigger is a &lt;code&gt;use cache&lt;/code&gt; function that returns multiple typed arrays. The advisory still says to &lt;strong&gt;upgrade immediately even if your application does not have similar functions&lt;/strong&gt;. New apps from &lt;code&gt;create-next-app&lt;/code&gt; 16.4 have Cache Components on by default, as I covered in &lt;a href="https://www.fabwaseem.com/blog/nextjs-use-cache" rel="noopener noreferrer"&gt;Next.js use cache explained&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SVG Image Optimization RCE (CVE-2026-96889).&lt;/strong&gt; A memory bug in librsvg, which &lt;code&gt;sharp&lt;/code&gt; uses to decode SVG, can lead to RCE when Next.js runs on &lt;strong&gt;glibc-based Linux&lt;/strong&gt;. That covers most Debian and Ubuntu based Docker images and VPS installs. By default Next.js does not optimize SVG images; apps that set &lt;code&gt;images.dangerouslyAllowSVG: true&lt;/code&gt; are the clearest exposure, but the advisory is not limited to that setting, so patch either way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server Actions DoS (GHSA-p3m5-59mp-jv5r).&lt;/strong&gt; Any App Router app with at least one Server Action, on Next.js 13 through 15.5.27 or 16.0.0 through 16.4.0. Pages Router apps and apps without Server Actions are not vulnerable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does &lt;code&gt;npm audit&lt;/code&gt; say my app is safe?
&lt;/h2&gt;

&lt;p&gt;Because the advisory feed it reads may not list these issues yet. On October 17, 2026, I installed &lt;code&gt;next@16.4.0&lt;/code&gt;, react and react-dom in an empty project and ran &lt;code&gt;npm audit&lt;/code&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;$ &lt;/span&gt;npm audit &lt;span class="nt"&gt;--json&lt;/span&gt; | jq .metadata.vulnerabilities
&lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"info"&lt;/span&gt;: 0, &lt;span class="s2"&gt;"low"&lt;/span&gt;: 0, &lt;span class="s2"&gt;"moderate"&lt;/span&gt;: 0, &lt;span class="s2"&gt;"high"&lt;/span&gt;: 0, &lt;span class="s2"&gt;"critical"&lt;/span&gt;: 0, &lt;span class="s2"&gt;"total"&lt;/span&gt;: 0 &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero findings on a version with two open critical advisories. Treat &lt;code&gt;npm audit&lt;/code&gt;, Dependabot and similar scanners as a backstop here and check the versions yourself, or use the script below.&lt;/p&gt;

&lt;h2&gt;
  
  
  A script that checks your app against all three advisories
&lt;/h2&gt;

&lt;p&gt;I wrote this to check every app I run. It reads the installed &lt;code&gt;next&lt;/code&gt; version, resolves &lt;code&gt;sharp&lt;/code&gt; from Next.js's own folder (the copy the image optimizer actually loads), scans your source for &lt;code&gt;use cache&lt;/code&gt;, &lt;code&gt;use server&lt;/code&gt; and &lt;code&gt;dangerouslyAllowSVG&lt;/code&gt;, and exits with code 1 if anything is unpatched, so you can drop it into CI. It has no dependencies.&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="c1"&gt;// check-next-security.mjs: checks a Next.js app against the October 14, 2026 advisories.&lt;/span&gt;
&lt;span class="c1"&gt;// Usage: node check-next-security.mjs [projectDir]&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;readFileSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;statSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;existsSync&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;node:fs&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;createRequire&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;node:module&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;path&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;node:path&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;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolve&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;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRequire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;package.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;// Find node_modules/&amp;lt;pkg&amp;gt;/package.json the way Node would, without relying on "exports".&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;package.json&lt;/span&gt;&lt;span class="dl"&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="nf"&gt;existsSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&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;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="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;version&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;v&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;v&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;'&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="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&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;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inRange&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hi&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;cmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;cmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="c1"&gt;// Scan source files for features the advisories depend on.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&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;walk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;readdirSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node_modules&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;.next&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;.git&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;out&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;dist&lt;/span&gt;&lt;span class="dl"&gt;'&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;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;statSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isDirectory&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.(&lt;/span&gt;&lt;span class="sr"&gt;m&lt;/span&gt;&lt;span class="se"&gt;?[&lt;/span&gt;&lt;span class="sr"&gt;jt&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;sx&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;useCache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;use cache&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;: &lt;/span&gt;&lt;span class="se"&gt;\w&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)?[&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;serverActions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;use server&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;svgOptimizer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/dangerouslyAllowSVG&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*:&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;*true/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;nextOg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/from &lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;next&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;og&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;'"&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;version&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&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;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next is not installed in&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dir&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="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Resolve sharp from next's own folder, since that is the copy the image optimizer loads.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextPkg&lt;/span&gt; &lt;span class="o"&gt;=&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;resolve&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="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;b&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;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&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;package.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existsSync&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;sharp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;version&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sharp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;createRequire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextPkg&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;major&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;checks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;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;GHSA-673q-mjm6-r5xr (critical): RCE in use cache returning typed arrays&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;vulnerable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;inRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;16.0.0&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;16.4.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;upgrade next to 16.4.1+ (no workaround)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;useCache&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app uses "use cache"&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;no "use cache" found, upgrade anyway&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="na"&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;CVE-2026-96889 / GHSA-2464-fhr4-w9xw (critical): RCE via SVG in image optimization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;vulnerable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;sharp&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;cmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.35.5&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;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="na"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sharp must be 0.35.5+ (npm update sharp), next 16.4.1+ or 15.5.28+&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`sharp &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sharp&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;not installed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;svgOptimizer&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, dangerouslyAllowSVG on&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="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextOg&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, uses next/og&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="p"&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;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;GHSA-p3m5-59mp-jv5r (high): DoS via Server Actions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;vulnerable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;inRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;13.0.0&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;15.5.27&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;inRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;16.0.0&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;16.4.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;fix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;major&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;upgrade next to 16.4.1+&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;upgrade next to 15.5.28+&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serverActions&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app defines Server Actions&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;no "use server" 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="p"&gt;]&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`next &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, sharp &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sharp&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;not installed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;bad&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;checks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vulnerable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&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="nx"&gt;vulnerable&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;VULNERABLE&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;ok        &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`            &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="nx"&gt;note&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="nx"&gt;vulnerable&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`\n            fix: &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="nx"&gt;fix&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="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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; issue(s) found. Patch, rebuild and redeploy.`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s1"&gt;All three October 14, 2026 advisories are patched.&lt;/span&gt;&lt;span class="dl"&gt;'&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="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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;Run it from your project root with &lt;code&gt;node check-next-security.mjs&lt;/code&gt;. This is the real output on a Next.js 16.4.0 app with one &lt;code&gt;use cache&lt;/code&gt; function and one Server Action, before and after &lt;code&gt;npm install next@16.4.2&lt;/code&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;$ &lt;/span&gt;node check-next-security.mjs
next 16.4.0, sharp 0.35.4 &lt;span class="k"&gt;in&lt;/span&gt; /app

VULNERABLE  GHSA-673q-mjm6-r5xr &lt;span class="o"&gt;(&lt;/span&gt;critical&lt;span class="o"&gt;)&lt;/span&gt;: RCE &lt;span class="k"&gt;in &lt;/span&gt;use cache returning typed arrays
            app uses &lt;span class="s2"&gt;"use cache"&lt;/span&gt;
            fix: upgrade next to 16.4.1+ &lt;span class="o"&gt;(&lt;/span&gt;no workaround&lt;span class="o"&gt;)&lt;/span&gt;
VULNERABLE  CVE-2026-96889 / GHSA-2464-fhr4-w9xw &lt;span class="o"&gt;(&lt;/span&gt;critical&lt;span class="o"&gt;)&lt;/span&gt;: RCE via SVG &lt;span class="k"&gt;in &lt;/span&gt;image optimization
            sharp 0.35.4
            fix: sharp must be 0.35.5+ &lt;span class="o"&gt;(&lt;/span&gt;npm update sharp&lt;span class="o"&gt;)&lt;/span&gt;, next 16.4.1+ or 15.5.28+
VULNERABLE  GHSA-p3m5-59mp-jv5r &lt;span class="o"&gt;(&lt;/span&gt;high&lt;span class="o"&gt;)&lt;/span&gt;: DoS via Server Actions
            app defines Server Actions
            fix: upgrade next to 16.4.1+

3 issue&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt; found. Patch, rebuild and redeploy.

&lt;span class="nv"&gt;$ &lt;/span&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;next@16.4.2 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; node check-next-security.mjs
next 16.4.2, sharp 0.35.5 &lt;span class="k"&gt;in&lt;/span&gt; /app

ok          GHSA-673q-mjm6-r5xr &lt;span class="o"&gt;(&lt;/span&gt;critical&lt;span class="o"&gt;)&lt;/span&gt;: RCE &lt;span class="k"&gt;in &lt;/span&gt;use cache returning typed arrays
            app uses &lt;span class="s2"&gt;"use cache"&lt;/span&gt;
ok          CVE-2026-96889 / GHSA-2464-fhr4-w9xw &lt;span class="o"&gt;(&lt;/span&gt;critical&lt;span class="o"&gt;)&lt;/span&gt;: RCE via SVG &lt;span class="k"&gt;in &lt;/span&gt;image optimization
            sharp 0.35.5
ok          GHSA-p3m5-59mp-jv5r &lt;span class="o"&gt;(&lt;/span&gt;high&lt;span class="o"&gt;)&lt;/span&gt;: DoS via Server Actions
            app defines Server Actions

All three October 14, 2026 advisories are patched.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script checks versions and source patterns; it is not an exploit test. A clean result means you are on patched versions, which is what the advisories ask for.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you upgrade Next.js 16 to the patched version?
&lt;/h2&gt;

&lt;p&gt;Install the latest 16.4 patch, then confirm &lt;code&gt;sharp&lt;/code&gt; moved with it. Next.js 16.4.1 raised its &lt;code&gt;sharp&lt;/code&gt; requirement to &lt;code&gt;^0.35.5&lt;/code&gt; (16.4.0 accepted &lt;code&gt;^0.35.4&lt;/code&gt;; I read both from the npm registry), so npm has to upgrade &lt;code&gt;sharp&lt;/code&gt; when you upgrade &lt;code&gt;next&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;next@16.4.2
npm &lt;span class="nb"&gt;ls &lt;/span&gt;next sharp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my test, a project whose lockfile held &lt;code&gt;next@16.4.0&lt;/code&gt; and &lt;code&gt;sharp@0.35.4&lt;/code&gt; came out of &lt;code&gt;npm install next@16.4.1&lt;/code&gt; with &lt;code&gt;sharp@0.35.5&lt;/code&gt;, as expected. Run the same check with &lt;code&gt;pnpm why sharp&lt;/code&gt; or &lt;code&gt;yarn why sharp&lt;/code&gt; if you use those package managers.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;eslint-config-next&lt;/code&gt; should match your &lt;code&gt;next&lt;/code&gt; version, so bump it in the same commit. If you have not moved to 16 yet, the &lt;a href="https://nextjs.org/docs/app/guides/upgrading/version-16" rel="noopener noreferrer"&gt;version 16 upgrade guide&lt;/a&gt; covers the breaking changes; that is a bigger job than this patch, so patch 15.x first.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you patch Next.js 15 safely?
&lt;/h2&gt;

&lt;p&gt;Upgrade to 15.5.28, then &lt;strong&gt;check &lt;code&gt;sharp&lt;/code&gt; separately&lt;/strong&gt;, because upgrading &lt;code&gt;next&lt;/code&gt; alone may not fix the SVG bug. Next.js 15.5.28 declares &lt;code&gt;sharp&lt;/code&gt; as &lt;code&gt;^0.34.3 || ^0.35.5&lt;/code&gt;, so an existing lockfile that already resolved &lt;code&gt;sharp&lt;/code&gt; 0.34.x still satisfies it, and npm keeps it.&lt;/p&gt;

&lt;p&gt;This is what happened on a real project with &lt;code&gt;next@15.5.27&lt;/code&gt; and &lt;code&gt;sharp@0.34.5&lt;/code&gt; in the lockfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;next@15.5.28
&lt;span class="nv"&gt;$ &lt;/span&gt;npm &lt;span class="nb"&gt;ls &lt;/span&gt;sharp
└─┬ next@15.5.28
  └── sharp@0.34.5        &lt;span class="c"&gt;# still vulnerable&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;npm update sharp
&lt;span class="nv"&gt;$ &lt;/span&gt;npm &lt;span class="nb"&gt;ls &lt;/span&gt;sharp
└─┬ next@15.5.28
  └── sharp@0.35.5        &lt;span class="c"&gt;# fixed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;sharp&lt;/code&gt; is a direct dependency in your &lt;code&gt;package.json&lt;/code&gt;, run &lt;code&gt;npm install sharp@0.35.5&lt;/code&gt; instead. To force one version across a monorepo, add an override:&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;"overrides"&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;"sharp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.35.5"&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;pnpm uses &lt;code&gt;pnpm.overrides&lt;/code&gt; and Yarn uses &lt;code&gt;resolutions&lt;/code&gt; for the same job.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you patch a self-hosted Next.js app in Docker or Coolify?
&lt;/h2&gt;

&lt;p&gt;Update the lockfile, rebuild the image without reusing old layers, redeploy, then check the versions inside the running container. A dependency fix only protects you once the container that serves traffic contains it, and a Docker build that copies &lt;code&gt;package-lock.json&lt;/code&gt; and runs &lt;code&gt;npm ci&lt;/code&gt; installs exactly what the lockfile says.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffyx9xeodnwx34jiddnp4.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffyx9xeodnwx34jiddnp4.jpg" alt="Six-step patch flow for self-hosted Next.js: update deps, check the lockfile, commit it, rebuild the image, redeploy with Coolify or docker compose, and verify the versions inside the container" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Update and verify locally.&lt;/strong&gt; &lt;code&gt;npm install next@16.4.2&lt;/code&gt;, &lt;code&gt;npm ls next sharp&lt;/code&gt;, run the checker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commit &lt;code&gt;package-lock.json&lt;/code&gt;.&lt;/strong&gt; Without it, your build server resolves whatever it resolved last time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rebuild the image.&lt;/strong&gt; With the standalone output used in my &lt;a href="https://www.fabwaseem.com/blog/nextjs-dockerfile" rel="noopener noreferrer"&gt;production Next.js Dockerfile&lt;/a&gt;, &lt;code&gt;next build&lt;/code&gt; copies &lt;code&gt;sharp&lt;/code&gt; and its native libvips package into &lt;code&gt;.next/standalone/node_modules&lt;/code&gt;. In my build that was &lt;code&gt;@img/sharp-libvips-linux-x64&lt;/code&gt;, the glibc variant. An image built before the patch keeps the old &lt;code&gt;sharp&lt;/code&gt; until you rebuild it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redeploy.&lt;/strong&gt; In Coolify, push the commit or click &lt;strong&gt;Redeploy&lt;/strong&gt;. To skip cached layers for this one build, trigger a forced deploy, for example through the Coolify API with &lt;code&gt;GET /api/v1/deploy?uuid=&amp;lt;app-uuid&amp;gt;&amp;amp;force=true&lt;/code&gt;. My &lt;a href="https://www.fabwaseem.com/blog/self-host-nextjs-with-coolify" rel="noopener noreferrer"&gt;self-host Next.js with Coolify guide&lt;/a&gt; covers the deploy setup. With plain Docker, run &lt;code&gt;docker compose build --no-cache &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the running container.&lt;/strong&gt; In a standalone image the app lives in &lt;code&gt;/app&lt;/code&gt;, so:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &amp;lt;container&amp;gt; node &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"console.log(require('/app/node_modules/next/package.json').version, require('/app/node_modules/sharp/package.json').version)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Requiring the &lt;code&gt;package.json&lt;/code&gt; files by absolute path works even though &lt;code&gt;sharp&lt;/code&gt; 0.35.5 does not export &lt;code&gt;./package.json&lt;/code&gt;. I checked the same command against a standalone build of Next.js 16.4.2; it printed &lt;code&gt;0.35.5 16.4.2&lt;/code&gt; for &lt;code&gt;sharp&lt;/code&gt; and &lt;code&gt;next&lt;/code&gt;. Adjust the path if your Dockerfile copies the app somewhere other than &lt;code&gt;/app&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if you cannot upgrade Next.js today?
&lt;/h2&gt;

&lt;p&gt;Only the SVG bug has an interim mitigation; the &lt;code&gt;use cache&lt;/code&gt; RCE and the Server Actions DoS have no workaround, so plan the upgrade within hours, not weeks. In the meantime:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bug&lt;/th&gt;
&lt;th&gt;Interim step&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SVG / &lt;code&gt;sharp&lt;/code&gt; RCE&lt;/td&gt;
&lt;td&gt;Make sure &lt;code&gt;sharp&lt;/code&gt; is 0.35.5 or later, even without upgrading &lt;code&gt;next&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/vercel/next.js/security/advisories/GHSA-2464-fhr4-w9xw" rel="noopener noreferrer"&gt;Next.js advisory&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SVG / &lt;code&gt;sharp&lt;/code&gt; RCE&lt;/td&gt;
&lt;td&gt;In your own code that calls &lt;code&gt;sharp&lt;/code&gt;, block SVG decoding with &lt;code&gt;sharp.block({ operation: ['VipsForeignLoadSvg'] })&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/lovell/sharp/security/advisories/GHSA-wq5f-xc86-pv6w" rel="noopener noreferrer"&gt;sharp advisory&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SVG / &lt;code&gt;sharp&lt;/code&gt; RCE&lt;/td&gt;
&lt;td&gt;Turn off &lt;code&gt;images.dangerouslyAllowSVG&lt;/code&gt; if you do not need optimized SVGs&lt;/td&gt;
&lt;td&gt;&lt;a href="https://nextjs.org/docs/app/api-reference/components/image" rel="noopener noreferrer"&gt;Image docs&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;use cache&lt;/code&gt; RCE&lt;/td&gt;
&lt;td&gt;None. Upgrade.&lt;/td&gt;
&lt;td&gt;Next.js advisory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server Actions DoS&lt;/td&gt;
&lt;td&gt;None. Upgrade. Rate limits only reduce the blast radius.&lt;/td&gt;
&lt;td&gt;Next.js advisory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I tested the &lt;code&gt;sharp.block&lt;/code&gt; call with &lt;code&gt;sharp&lt;/code&gt; 0.35.5: before it, a small SVG converted to a 100-byte PNG; after it, the same call failed with &lt;code&gt;Input buffer contains unsupported image format&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;sharp&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;sharp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// Refuse to decode SVG input anywhere this sharp instance is used.&lt;/span&gt;
&lt;span class="nx"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;block&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;operation&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;VipsForeignLoadSvg&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 sharp advisory adds one hardening note: whether the librsvg bug can reach code execution depends partly on the Node.js binary. It recommends a &lt;code&gt;node&lt;/code&gt; built as a Position Independent Executable (PIE), notes that most Linux distribution packages already are, and warns that the official Node.js binaries are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you know whether you were attacked?
&lt;/h2&gt;

&lt;p&gt;The advisories publish no indicators of compromise and do not mention exploitation in the wild, so look for the side effects each bug would leave, and re-check the advisories for updates.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DoS:&lt;/strong&gt; sustained 100% CPU on a Next.js process together with slow or failed requests, especially &lt;code&gt;POST&lt;/code&gt; requests carrying a &lt;code&gt;Next-Action&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SVG RCE:&lt;/strong&gt; unexpected requests to &lt;code&gt;/_next/image&lt;/code&gt; with &lt;code&gt;.svg&lt;/code&gt; sources, crashes of the Node.js process during image optimization, and new child processes or outbound connections from the container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any RCE:&lt;/strong&gt; unknown files in the container filesystem, changed environment variables or secrets used from new IPs. If you find any of these, rotate secrets (database URLs, API keys, &lt;code&gt;NEXT_SERVER_ACTIONS_ENCRYPTION_KEY&lt;/code&gt;) after patching.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Security habits that make the next patch faster
&lt;/h2&gt;

&lt;p&gt;This was an out-of-band release that landed eight days after 16.4.0 shipped on October 6. A few habits keep the next one to a ten-minute job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stay on an LTS line.&lt;/strong&gt; Security fixes land on 16.4.x (Active LTS) and 15.5.x (Maintenance LTS).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on upgrade nudges.&lt;/strong&gt; Next.js 16.4 added &lt;code&gt;experimental.agentUpgrade&lt;/code&gt;, whose default &lt;code&gt;'security'&lt;/code&gt; policy reminds you during &lt;code&gt;next dev&lt;/code&gt; and &lt;code&gt;next build&lt;/code&gt; when an upgrade fixes a known vulnerability in your installed version (&lt;a href="https://nextjs.org/blog/next-16-4" rel="noopener noreferrer"&gt;Next.js 16.4 notes&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the checker in CI.&lt;/strong&gt; Exit code 1 fails the build when a vulnerable version is installed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin and commit lockfiles,&lt;/strong&gt; then rebuild images from them so the running version is the one you reviewed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep Server Actions small and authenticated.&lt;/strong&gt; The DoS needs only one action to exist, but auth and input validation limit what any future bug can reach; see &lt;a href="https://www.fabwaseem.com/blog/server-actions-vs-route-handlers" rel="noopener noreferrer"&gt;Server Actions vs Route Handlers&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common mistakes when patching
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mistake&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Upgrading &lt;code&gt;react&lt;/code&gt; instead of &lt;code&gt;next&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Next.js keeps its bundled RSC runtime&lt;/td&gt;
&lt;td&gt;Upgrade &lt;code&gt;next&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upgrading 15.x to 15.5.28 and stopping&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sharp&lt;/code&gt; 0.34.x can stay in the lockfile&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npm update sharp&lt;/code&gt;, confirm 0.35.5+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trusting &lt;code&gt;npm audit&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;It reported 0 issues on unpatched 16.4.0&lt;/td&gt;
&lt;td&gt;Check versions or run the script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not committing the lockfile&lt;/td&gt;
&lt;td&gt;CI or Coolify rebuilds the old versions&lt;/td&gt;
&lt;td&gt;Commit &lt;code&gt;package-lock.json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rebuilding from a cached layer&lt;/td&gt;
&lt;td&gt;Old &lt;code&gt;node_modules&lt;/code&gt; layer is reused&lt;/td&gt;
&lt;td&gt;Build once with no cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checking only the laptop&lt;/td&gt;
&lt;td&gt;Production still runs the old image&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;docker exec&lt;/code&gt; into the running container&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; I packaged the checker script above as a reusable agent skill, so Claude Code, Cursor, Codex or Copilot can run the check and patch for you. See &lt;a href="https://www.fabwaseem.com/blog/agent-skills" rel="noopener noreferrer"&gt;Agent Skills: how to write a SKILL.md&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; running an MCP server next to your Next.js app? &lt;a href="https://www.fabwaseem.com/blog/mcp-security" rel="noopener noreferrer"&gt;MCP security in 2026&lt;/a&gt; covers this month's MCP SDK OAuth advisories and how to fix them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The October 14, 2026 update fixes two critical RCEs (&lt;code&gt;use cache&lt;/code&gt; typed arrays and SVG via &lt;code&gt;sharp&lt;/code&gt;) and a high-severity Server Actions DoS.&lt;/li&gt;
&lt;li&gt;Patched versions are Next.js 16.4.1+ and 15.5.28+, with &lt;code&gt;sharp&lt;/code&gt; 0.35.5 or later.&lt;/li&gt;
&lt;li&gt;On 15.x, upgrading &lt;code&gt;next&lt;/code&gt; can leave &lt;code&gt;sharp&lt;/code&gt; 0.34.x installed; run &lt;code&gt;npm update sharp&lt;/code&gt; and verify.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;npm audit&lt;/code&gt; reported nothing for unpatched &lt;code&gt;next@16.4.0&lt;/code&gt; on October 17, 2026, so check versions directly.&lt;/li&gt;
&lt;li&gt;A self-hosted fix only counts after you rebuild the image, redeploy and verify the versions inside the running container.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/nextjs-security-update-october-2026" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>security</category>
      <category>webdev</category>
      <category>react</category>
    </item>
    <item>
      <title>Agent Skills Explained: How to Write a SKILL.md for Claude Code, Cursor, Codex and Copilot</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Fri, 23 Oct 2026 19:03:05 +0000</pubDate>
      <link>https://dev.to/fabwaseem/agent-skills-explained-how-to-write-a-skillmd-for-claude-code-cursor-codex-and-copilot-323p</link>
      <guid>https://dev.to/fabwaseem/agent-skills-explained-how-to-write-a-skillmd-for-claude-code-cursor-codex-and-copilot-323p</guid>
      <description>&lt;p&gt;Agent Skills are now the standard way to teach an AI coding agent a repeatable job. Write one &lt;code&gt;SKILL.md&lt;/code&gt; file and Claude Code, Cursor, OpenAI Codex, GitHub Copilot and VS Code can all load it. Next.js 16.4 even ships its own skills that you install with &lt;code&gt;npx skills add&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In this guide I build a real skill, &lt;code&gt;next-security-check&lt;/code&gt;, that checks a Next.js app against the October 2026 security advisories. I validate it with the official &lt;code&gt;skills-ref&lt;/code&gt; validator and install it with the &lt;code&gt;skills&lt;/code&gt; CLI. Along the way it covers the SKILL.md format, the folder each agent actually reads, and the mistakes that stop a skill from triggering. Every rule below is cited to the &lt;a href="https://agentskills.io/specification" rel="noopener noreferrer"&gt;Agent Skills specification&lt;/a&gt; or to each agent's own docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are Agent Skills?
&lt;/h2&gt;

&lt;p&gt;Agent Skills are folders of instructions, scripts and reference files that an AI agent loads on demand to do a specific task. Each skill is a directory with a required &lt;code&gt;SKILL.md&lt;/code&gt; file: YAML frontmatter with a &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt;, then Markdown instructions. The agent reads only the name and description at startup and loads the rest when a task matches.&lt;/p&gt;

&lt;p&gt;According to the &lt;a href="https://agentskills.io/home" rel="noopener noreferrer"&gt;Agent Skills overview&lt;/a&gt;, the format "was originally developed by Anthropic, released as an open standard, and has been adopted by a growing number of agent products." The &lt;a href="https://agentskills.io/clients" rel="noopener noreferrer"&gt;client showcase&lt;/a&gt; lists the tools that support it, including Claude Code, Cursor, OpenAI Codex, GitHub Copilot, VS Code, Gemini CLI, OpenCode, Goose and JetBrains Junie.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Skill&lt;/td&gt;
&lt;td&gt;A directory containing &lt;code&gt;SKILL.md&lt;/code&gt; plus optional &lt;code&gt;scripts/&lt;/code&gt;, &lt;code&gt;references/&lt;/code&gt;, &lt;code&gt;assets/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SKILL.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Frontmatter (&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, optional fields) followed by Markdown instructions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Activation&lt;/td&gt;
&lt;td&gt;The agent decides a skill is relevant, or you invoke it with &lt;code&gt;/skill-name&lt;/code&gt; or &lt;code&gt;$skill-name&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Progressive disclosure&lt;/td&gt;
&lt;td&gt;Loading metadata first, the body on activation, and resources only when needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;skills-ref&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The reference validator from the Agent Skills project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;skills&lt;/code&gt; CLI&lt;/td&gt;
&lt;td&gt;Vercel Labs' installer (&lt;code&gt;npx skills add&lt;/code&gt;) that copies or links skills into each agent's folder&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why do Agent Skills matter right now?
&lt;/h2&gt;

&lt;p&gt;Skills matter because they are the first agent customization format that works across vendors. The same folder runs in Claude Code, Cursor, Codex and Copilot. Framework teams now ship them: the &lt;a href="https://nextjs.org/blog/next-16-4" rel="noopener noreferrer"&gt;Next.js 16.4 release&lt;/a&gt; tells you to run &lt;code&gt;npx skills add vercel/next.js --skill next-bundle-optimizer&lt;/code&gt;, then &lt;code&gt;/next-bundle-optimizer&lt;/code&gt; "in your AI agent of choice."&lt;/p&gt;

&lt;p&gt;Running &lt;code&gt;npx skills add vercel/next.js --list&lt;/code&gt; on October 17, 2026 showed the official Next.js skills, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;next-bundle-optimizer&lt;/code&gt;: audits and cuts browser initial-load work&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;next-cache-components-adoption&lt;/code&gt;: turns on Cache Components and fixes the blocking routes it surfaces&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;next-cache-components-optimizer&lt;/code&gt;: grows the static shell, guarded by &lt;code&gt;instant()&lt;/code&gt; tests&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;next-partial-prefetching-adoption&lt;/code&gt; and &lt;code&gt;next-partial-prefetching-optimizer&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;next-dev-loop&lt;/code&gt;: verifies runtime behavior against a running &lt;code&gt;next dev&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you already followed my &lt;a href="https://www.fabwaseem.com/blog/nextjs-use-cache" rel="noopener noreferrer"&gt;Next.js use cache guide&lt;/a&gt;, &lt;code&gt;next-cache-components-adoption&lt;/code&gt; is the skill that automates that migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  How is a skill different from AGENTS.md, rules and MCP?
&lt;/h2&gt;

&lt;p&gt;A skill is an on-demand procedure. AGENTS.md and rules files are always-on context. An MCP server is a live tool connection. Use AGENTS.md for facts every task needs, a skill for a multi-step job that only some tasks need, and MCP when the agent must call an external system such as a database or an API.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;AGENTS.md / CLAUDE.md / rules&lt;/th&gt;
&lt;th&gt;Agent Skill (&lt;code&gt;SKILL.md&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;MCP server&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Loaded&lt;/td&gt;
&lt;td&gt;Every session&lt;/td&gt;
&lt;td&gt;Name and description at startup; body on activation&lt;/td&gt;
&lt;td&gt;Tool list at startup; calls at runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Conventions, commands, project facts&lt;/td&gt;
&lt;td&gt;Repeatable procedures, checklists, scripts&lt;/td&gt;
&lt;td&gt;Live data and actions in other systems&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contains&lt;/td&gt;
&lt;td&gt;Markdown&lt;/td&gt;
&lt;td&gt;Markdown + scripts + references + assets&lt;/td&gt;
&lt;td&gt;Running code (stdio or HTTP)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context cost&lt;/td&gt;
&lt;td&gt;Always paid&lt;/td&gt;
&lt;td&gt;About 100 tokens until used&lt;/td&gt;
&lt;td&gt;Tool schemas, paid per server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Portable&lt;/td&gt;
&lt;td&gt;Mostly, file names differ&lt;/td&gt;
&lt;td&gt;Yes, one open spec&lt;/td&gt;
&lt;td&gt;Yes, one open protocol&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Claude Code's docs put it well: create a skill "when you keep pasting the same instructions, checklist, or multi-step procedure into chat, or when a section of CLAUDE.md has grown into a procedure" (&lt;a href="https://code.claude.com/docs/en/skills" rel="noopener noreferrer"&gt;Claude Code skills&lt;/a&gt;). For the always-on side, see &lt;a href="https://www.fabwaseem.com/blog/agents-md-vs-claude-md" rel="noopener noreferrer"&gt;AGENTS.md vs CLAUDE.md vs Cursor rules&lt;/a&gt;. For live tools, see &lt;a href="https://www.fabwaseem.com/blog/build-mcp-server-nextjs" rel="noopener noreferrer"&gt;how to build an MCP server in Next.js&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the SKILL.md format look like?
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;SKILL.md&lt;/code&gt; file starts with YAML frontmatter between &lt;code&gt;---&lt;/code&gt; lines, then free-form Markdown. The &lt;a href="https://agentskills.io/specification" rel="noopener noreferrer"&gt;specification&lt;/a&gt; requires only two fields, &lt;code&gt;name&lt;/code&gt; and &lt;code&gt;description&lt;/code&gt;. &lt;code&gt;license&lt;/code&gt;, &lt;code&gt;compatibility&lt;/code&gt;, &lt;code&gt;metadata&lt;/code&gt; and the experimental &lt;code&gt;allowed-tools&lt;/code&gt; are optional. The body has no required structure; write the steps the agent should follow.&lt;/p&gt;

&lt;p&gt;The folder layout for the skill in this guide:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.agents/skills/next-security-check/
├── SKILL.md                  # required: frontmatter + instructions
├── scripts/
│   └── check-next-security.mjs   # code the agent runs
└── references/
    └── advisories.md         # loaded only when SKILL.md points to it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Which frontmatter fields does the spec define?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Required&lt;/th&gt;
&lt;th&gt;Rule from the spec&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;1-64 chars; lowercase letters, numbers and hyphens; no leading, trailing or consecutive hyphens; must match the folder name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;1-1024 chars; what the skill does &lt;strong&gt;and&lt;/strong&gt; when to use it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;license&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;License name or bundled license file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;compatibility&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Up to 500 chars; required tools, network access, target product&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;metadata&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;String-to-string map, e.g. &lt;code&gt;author&lt;/code&gt;, &lt;code&gt;version&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;allowed-tools&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Space-separated pre-approved tools, e.g. &lt;code&gt;Bash(git:*) Read&lt;/code&gt;; experimental, support varies&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Agents add their own optional fields on top. Claude Code, Cursor and VS Code all support &lt;code&gt;disable-model-invocation: true&lt;/code&gt; to make a skill run only when you type &lt;code&gt;/skill-name&lt;/code&gt;. Claude Code and VS Code also support &lt;code&gt;user-invocable: false&lt;/code&gt; to hide a background skill from the &lt;code&gt;/&lt;/code&gt; menu (&lt;a href="https://code.visualstudio.com/docs/copilot/customization/agent-skills" rel="noopener noreferrer"&gt;VS Code agent skills&lt;/a&gt;, &lt;a href="https://cursor.com/docs/context/skills" rel="noopener noreferrer"&gt;Cursor skills&lt;/a&gt;). Other agents ignore fields they don't know. Claude Code says it "ignores a field it doesn't recognize without reporting an error."&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I create an Agent Skill step by step?
&lt;/h2&gt;

&lt;p&gt;To create an Agent Skill, make a folder named after the skill inside &lt;code&gt;.agents/skills/&lt;/code&gt;, add a &lt;code&gt;SKILL.md&lt;/code&gt; with a &lt;code&gt;name&lt;/code&gt; that matches the folder and a &lt;code&gt;description&lt;/code&gt; that says when to use it, then write numbered steps in the body. Put long material in &lt;code&gt;references/&lt;/code&gt;, code in &lt;code&gt;scripts/&lt;/code&gt;, and validate with &lt;code&gt;skills-ref validate&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create the folder
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; .agents/skills/next-security-check/&lt;span class="o"&gt;{&lt;/span&gt;scripts,references&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Write SKILL.md
&lt;/h3&gt;

&lt;p&gt;This is the exact file I validated and installed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;next-security-check&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checks a Next.js app against known Next.js security advisories and patches it. Use when the user asks whether their Next.js app is vulnerable, mentions a Next.js CVE or GHSA, asks to upgrade Next.js for security, or before deploying a Next.js app.&lt;/span&gt;
&lt;span class="na"&gt;license&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MIT&lt;/span&gt;
&lt;span class="na"&gt;compatibility&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Requires Node.js 18+ and npm, pnpm or yarn in a Next.js project&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fabwaseem&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.0"&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="gh"&gt;# Next.js security check&lt;/span&gt;

&lt;span class="gu"&gt;## Steps&lt;/span&gt;
&lt;span class="p"&gt;
1.&lt;/span&gt; Run the checker from the project root:
   &lt;span class="sb"&gt;`node .agents/skills/next-security-check/scripts/check-next-security.mjs .`&lt;/span&gt;
   Exit code 0 means patched, 1 means at least one advisory is open.
&lt;span class="p"&gt;2.&lt;/span&gt; If it reports VULNERABLE, read &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;references/advisories.md&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;references/advisories.md&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   for the fixed versions, then upgrade:
&lt;span class="p"&gt;   -&lt;/span&gt; Next.js 16.x: &lt;span class="sb"&gt;`npm install next@latest eslint-config-next@latest`&lt;/span&gt;
&lt;span class="p"&gt;   -&lt;/span&gt; Next.js 15.x: &lt;span class="sb"&gt;`npm install next@15.5.28`&lt;/span&gt;, then &lt;span class="sb"&gt;`npm update sharp`&lt;/span&gt;
&lt;span class="p"&gt;3.&lt;/span&gt; Run the checker again. Do not report success until it exits 0.
&lt;span class="p"&gt;4.&lt;/span&gt; Run the project's own checks (&lt;span class="sb"&gt;`npm run lint`&lt;/span&gt;, &lt;span class="sb"&gt;`npm run build`&lt;/span&gt;) and report
   any failures instead of hiding them.
&lt;span class="p"&gt;5.&lt;/span&gt; Tell the user to commit the lockfile, rebuild the Docker image without cache
   and redeploy. A patched laptop does not patch production.

&lt;span class="gu"&gt;## Gotchas&lt;/span&gt;
&lt;span class="p"&gt;
-&lt;/span&gt; &lt;span class="sb"&gt;`npm audit`&lt;/span&gt; can miss these advisories. Trust the checker's version comparison.
&lt;span class="p"&gt;-&lt;/span&gt; On Next.js 15.5.28 the lockfile can keep &lt;span class="sb"&gt;`sharp`&lt;/span&gt; 0.34.x. Always re-check &lt;span class="sb"&gt;`sharp`&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; Upgrading &lt;span class="sb"&gt;`react`&lt;/span&gt; does not fix Next.js: Next.js bundles its own React Server
  Components runtime. Upgrade &lt;span class="sb"&gt;`next`&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; Never run &lt;span class="sb"&gt;`npm audit fix --force`&lt;/span&gt;; it can jump major versions.

&lt;span class="gu"&gt;## Output format&lt;/span&gt;

Report a short table: advisory, status before, status after, action taken.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Gotchas section holds what I found the hard way while writing the &lt;a href="https://www.fabwaseem.com/blog/nextjs-security-update-october-2026" rel="noopener noreferrer"&gt;Next.js security update guide&lt;/a&gt;. The best-practices guide says "the highest-value content in many skills is a list of gotchas," because they are facts the agent can't guess (&lt;a href="https://agentskills.io/skill-creation/best-practices" rel="noopener noreferrer"&gt;Agent Skills best practices&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Bundle the script and the reference
&lt;/h3&gt;

&lt;p&gt;Copy the dependency-free checker from the security post into &lt;code&gt;scripts/check-next-security.mjs&lt;/code&gt;, and keep the advisory table in &lt;code&gt;references/advisories.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Next.js advisories checked by this skill&lt;/span&gt;

| Advisory | Severity | Affected next | Fixed in | Workaround |
| --- | --- | --- | --- | --- |
| GHSA-673q-mjm6-r5xr: RCE in &lt;span class="sb"&gt;`use cache`&lt;/span&gt; returning typed arrays | Critical 9.5 | 16.0.0 to 16.4.0 | 16.4.1 | None |
| CVE-2026-96889 / GHSA-2464-fhr4-w9xw: RCE via SVG in image optimization (sharp, glibc) | Critical 9.5 | 10.0.0 to 15.5.27, 16.0.0 to 16.4.0 | 15.5.28, 16.4.1 (sharp 0.35.5+) | sharp 0.35.5+ |
| GHSA-p3m5-59mp-jv5r: DoS via Server Actions | High 8.2 | 13.0.0 to 15.5.27, 16.0.0 to 16.4.0 | 15.5.28, 16.4.1 | None |

Source: https://nextjs.org/blog/nextjs-security-update-october-14-2026
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent only reads this file when step 2 sends it there. That is progressive disclosure working as designed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Test the script on its own
&lt;/h3&gt;

&lt;p&gt;A skill is only as good as its script, so run it outside the agent first. On a project with &lt;code&gt;next@16.4.0&lt;/code&gt; it exits 1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VULNERABLE  GHSA-p3m5-59mp-jv5r (high): DoS via Server Actions
            fix: upgrade next to 16.4.1+

2 issue(s) found. Patch, rebuild and redeploy.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the same project after upgrading to &lt;code&gt;next@16.4.2&lt;/code&gt; it exits 0 with &lt;code&gt;All three October 14, 2026 advisories are patched.&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I validate a SKILL.md file?
&lt;/h2&gt;

&lt;p&gt;Validate a skill with &lt;code&gt;skills-ref validate &amp;lt;folder&amp;gt;&lt;/code&gt;, the reference validator from the Agent Skills project. It checks the frontmatter against the spec: required fields, name format, name matching the directory, and field lengths. It exits 0 when the skill is valid and 1 with a list of errors otherwise, so it works in CI.&lt;/p&gt;

&lt;p&gt;Install it from the official repository (the project calls it a reference library "for demonstration purposes only", so treat it as a linter, not a runtime):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"git+https://github.com/agentskills/agentskills.git#subdirectory=skills-ref"&lt;/span&gt;
skills-ref validate .agents/skills/next-security-check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real output from my run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Valid skill: .agents/skills/next-security-check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the three mistakes I tested on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ skills-ref validate bad/Next_Security
Validation failed for bad/Next_Security:
  - Skill name 'Next_Security' must be lowercase
  - Skill name 'Next_Security' contains invalid characters. Only letters, digits, and hyphens are allowed.

$ skills-ref validate bad/next-check
Validation failed for bad/next-check:
  - Directory name 'next-check' must match skill name 'next-security-check'

$ skills-ref validate bad/nodesc
Validation failed for bad/nodesc:
  - Missing required field in frontmatter: description
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;skills-ref to-prompt &amp;lt;folder&amp;gt;&lt;/code&gt; prints the &lt;code&gt;&amp;lt;available_skills&amp;gt;&lt;/code&gt; XML block an agent would inject: name, description and location. Read it to see your skill the way the model sees it before activation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does each agent look for skills?
&lt;/h2&gt;

&lt;p&gt;Each agent scans its own folders, but they overlap. &lt;code&gt;.agents/skills/&lt;/code&gt; is read by Codex, Cursor, VS Code and GitHub Copilot. &lt;code&gt;.claude/skills/&lt;/code&gt; is read by Claude Code, Cursor, VS Code and Copilot. Claude Code does not list &lt;code&gt;.agents/skills/&lt;/code&gt;, so keep the real folder in &lt;code&gt;.agents/skills/&lt;/code&gt; and symlink it into &lt;code&gt;.claude/skills/&lt;/code&gt;.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ewhavw441lwyawn25ye.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9ewhavw441lwyawn25ye.jpg" alt="Map of agent skill folders: .agents/skills is read by Codex, Cursor, VS Code and Copilot; .claude/skills by Claude Code, Cursor, VS Code and Copilot; .github/skills by VS Code and Copilot; .cursor/skills by Cursor" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Project folders&lt;/th&gt;
&lt;th&gt;Personal folders&lt;/th&gt;
&lt;th&gt;Manual invoke&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.claude/skills/&lt;/code&gt; (also nested &lt;code&gt;&amp;lt;subdir&amp;gt;/.claude/skills/&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/.claude/skills/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/skill-name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.claude.com/docs/en/skills" rel="noopener noreferrer"&gt;docs&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cursor&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.agents/skills/&lt;/code&gt;, &lt;code&gt;.cursor/skills/&lt;/code&gt;, plus &lt;code&gt;.claude/skills/&lt;/code&gt; and &lt;code&gt;.codex/skills/&lt;/code&gt; for compatibility&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.agents/skills/&lt;/code&gt;, &lt;code&gt;~/.cursor/skills/&lt;/code&gt;, &lt;code&gt;~/.claude/skills/&lt;/code&gt;, &lt;code&gt;~/.codex/skills/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/&lt;/code&gt; then the skill name&lt;/td&gt;
&lt;td&gt;&lt;a href="https://cursor.com/docs/context/skills" rel="noopener noreferrer"&gt;docs&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI Codex&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.agents/skills/&lt;/code&gt; in every directory from the working directory up to the repo root&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$HOME/.agents/skills&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/skills&lt;/code&gt; or &lt;code&gt;$skill-name&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://developers.openai.com/codex/skills" rel="noopener noreferrer"&gt;docs&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VS Code (Copilot Chat)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.github/skills/&lt;/code&gt;, &lt;code&gt;.claude/skills/&lt;/code&gt;, &lt;code&gt;.agents/skills/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.copilot/skills/&lt;/code&gt;, &lt;code&gt;~/.claude/skills/&lt;/code&gt;, &lt;code&gt;~/.agents/skills/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/skill-name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://code.visualstudio.com/docs/copilot/customization/agent-skills" rel="noopener noreferrer"&gt;docs&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Copilot&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.github/skills&lt;/code&gt;, &lt;code&gt;.claude/skills&lt;/code&gt;, &lt;code&gt;.agents/skills&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.copilot/skills&lt;/code&gt;, &lt;code&gt;~/.agents/skills&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;per client&lt;/td&gt;
&lt;td&gt;&lt;a href="https://docs.github.com/en/copilot/concepts/agents/about-agent-skills" rel="noopener noreferrer"&gt;docs&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You don't have to set up the symlink yourself. When I installed the skill with the &lt;code&gt;skills&lt;/code&gt; CLI for Claude Code, Codex and Cursor, it did exactly that, as the next section shows.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I install skills with npx skills add?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;npx skills add &amp;lt;source&amp;gt;&lt;/code&gt; installs skills from a GitHub repo, a URL or a local folder. It writes one canonical copy to &lt;code&gt;.agents/skills/&amp;lt;name&amp;gt;/&lt;/code&gt; and symlinks it into agent folders that need a different path, such as &lt;code&gt;.claude/skills/&lt;/code&gt;. It also records the source and a content hash in &lt;code&gt;skills-lock.json&lt;/code&gt; so installs are reproducible.&lt;/p&gt;

&lt;p&gt;The CLI is &lt;a href="https://github.com/vercel-labs/skills" rel="noopener noreferrer"&gt;vercel-labs/skills&lt;/a&gt; (version 1.7.2 on npm when I tested it). One thing tripped me up: it needs &lt;strong&gt;Node.js 22.20 or later&lt;/strong&gt;. On Node 20 it crashes with &lt;code&gt;SyntaxError: The requested module 'fs/promises' does not provide an export named 'glob'&lt;/code&gt;. On Node 24 it worked:&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;# see what a repo offers&lt;/span&gt;
npx skills add vercel/next.js &lt;span class="nt"&gt;--list&lt;/span&gt;

&lt;span class="c"&gt;# install one official Next.js skill&lt;/span&gt;
npx skills add vercel/next.js &lt;span class="nt"&gt;--skill&lt;/span&gt; next-cache-components-adoption

&lt;span class="c"&gt;# install your own local skill for three agents, no prompts&lt;/span&gt;
npx skills add ./.agents/skills/next-security-check &lt;span class="nt"&gt;-a&lt;/span&gt; claude-code &lt;span class="nt"&gt;-a&lt;/span&gt; codex &lt;span class="nt"&gt;-a&lt;/span&gt; cursor &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result in a fresh project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;◇  Installed 1 skill
│  ✓ ./.agents/skills/next-security-check
│    universal: Codex, Cursor
│    symlinked: Claude Code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.agents/skills/next-security-check/SKILL.md
.claude/skills/next-security-check -&amp;gt; ../../.agents/skills/next-security-check
skills-lock.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On GitHub, the &lt;code&gt;gh skill&lt;/code&gt; command in GitHub CLI can also discover and install skills from repositories (&lt;a href="https://docs.github.com/en/copilot/concepts/agents/about-agent-skills" rel="noopener noreferrer"&gt;GitHub Docs&lt;/a&gt;). Other useful &lt;code&gt;skills&lt;/code&gt; CLI commands: &lt;code&gt;npx skills ls&lt;/code&gt; lists installed skills, &lt;code&gt;npx skills update&lt;/code&gt; updates them, &lt;code&gt;npx skills remove&lt;/code&gt; removes them, and &lt;code&gt;npx skills init my-skill&lt;/code&gt; scaffolds a new &lt;code&gt;SKILL.md&lt;/code&gt;. Commit &lt;code&gt;.agents/&lt;/code&gt;, &lt;code&gt;.claude/skills&lt;/code&gt; and &lt;code&gt;skills-lock.json&lt;/code&gt; so the whole team gets the same skills.&lt;/p&gt;

&lt;p&gt;The CLI ends every install with a warning worth repeating: "Review skills before use; they run with full agent permissions." Read a third-party skill's &lt;code&gt;SKILL.md&lt;/code&gt; and scripts before you install it, exactly as you would read a &lt;code&gt;postinstall&lt;/code&gt; script.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does progressive disclosure keep context small?
&lt;/h2&gt;

&lt;p&gt;Progressive disclosure means the agent loads a skill in three stages. The name and description (about 100 tokens) load at startup for every skill. The full &lt;code&gt;SKILL.md&lt;/code&gt; body (under 5,000 tokens recommended) loads when the skill activates. Files in &lt;code&gt;scripts/&lt;/code&gt;, &lt;code&gt;references/&lt;/code&gt; and &lt;code&gt;assets/&lt;/code&gt; load only when the instructions point to them.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fieqfo0vbww05pl1vu4nt.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fieqfo0vbww05pl1vu4nt.jpg" alt="Progressive disclosure in three levels: metadata of about 100 tokens loaded at startup, the SKILL.md body under 5,000 tokens loaded on activation, and scripts, references and assets loaded only when needed" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://agentskills.io/specification" rel="noopener noreferrer"&gt;specification&lt;/a&gt; gives two size rules that follow from this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keep the main &lt;code&gt;SKILL.md&lt;/code&gt; under 500 lines and move detailed material to separate files.&lt;/li&gt;
&lt;li&gt;Keep file references one level deep from &lt;code&gt;SKILL.md&lt;/code&gt;, and avoid chains like &lt;code&gt;SKILL.md → a.md → b.md&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because only metadata is always loaded, you can install dozens of skills for little context. Claude Code caps the listing too. It truncates the combined &lt;code&gt;description&lt;/code&gt; and &lt;code&gt;when_to_use&lt;/code&gt; text at 1,536 characters per skill, so it tells you to put the key use case first.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I write a description that actually triggers?
&lt;/h2&gt;

&lt;p&gt;Write the description as two sentences: what the skill does, then "Use when..." followed by the words a user would actually type. Agents choose skills by matching the task against the description, so vague text like "Helps with Next.js" never fires. Name concrete triggers: error messages, file names, tool names and intents.&lt;/p&gt;

&lt;p&gt;Compare:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Weak description&lt;/th&gt;
&lt;th&gt;Strong description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Helps with Next.js security.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Checks a Next.js app against known Next.js security advisories and patches it. Use when the user asks whether their Next.js app is vulnerable, mentions a Next.js CVE or GHSA, asks to upgrade Next.js for security, or before deploying a Next.js app.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Database stuff.&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Writes and reviews Prisma migrations for PostgreSQL. Use when the user edits schema.prisma, asks for a migration, or sees a P3005 or drift error.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;OpenAI's Codex docs say the same: "write concise descriptions with clear scope and boundaries. Front-load the key use case and trigger words" (&lt;a href="https://developers.openai.com/codex/skills" rel="noopener noreferrer"&gt;Codex skills&lt;/a&gt;). If a skill should never fire on its own, such as a deploy or a release, set &lt;code&gt;disable-model-invocation: true&lt;/code&gt; in clients that support it and invoke it by name.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes a skill good? Best practices checklist
&lt;/h2&gt;

&lt;p&gt;A good skill encodes knowledge the agent doesn't already have, gives an order of steps, and checks its own work. The &lt;a href="https://agentskills.io/skill-creation/best-practices" rel="noopener noreferrer"&gt;best-practices guide&lt;/a&gt; recommends starting from a real task you have done, adding gotchas, providing templates and checklists, and building validation loops where the agent runs a check, fixes the problem and runs it again.&lt;/p&gt;

&lt;p&gt;Use this checklist before you commit a skill:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One job per skill.&lt;/strong&gt; "Check and patch Next.js security" is one job; "all things Next.js" is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name matches the folder&lt;/strong&gt;, lowercase with hyphens, 64 characters or fewer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Description says what and when&lt;/strong&gt;, with the trigger words a user would type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numbered steps&lt;/strong&gt; with exact commands, not advice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Gotchas section&lt;/strong&gt; with the non-obvious facts you learned the hard way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A validation loop&lt;/strong&gt;: run a check, fix, run it again, and only then report success (step 3 in the example).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scripts for anything deterministic.&lt;/strong&gt; A script that exits 0 or 1 beats a paragraph the model has to interpret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;References for long material&lt;/strong&gt;, linked from &lt;code&gt;SKILL.md&lt;/code&gt;, one level deep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An output format&lt;/strong&gt; so results look the same every run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;skills-ref validate&lt;/code&gt; passes&lt;/strong&gt; in CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No secrets in the folder.&lt;/strong&gt; Skills are committed and shared; read tokens from the environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tested by hand&lt;/strong&gt; in at least one agent with a prompt that should trigger the skill and one that shouldn't.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Common Agent Skill mistakes
&lt;/h2&gt;

&lt;p&gt;The most common reasons a skill doesn't trigger or doesn't work are a folder name that doesn't match &lt;code&gt;name&lt;/code&gt;, a vague description, a file in a folder the agent doesn't scan, and frontmatter that doesn't start on line 1. Claude Code says that if the opening &lt;code&gt;---&lt;/code&gt; isn't the first line it treats the whole file, markers included, as skill content.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Skill never appears&lt;/td&gt;
&lt;td&gt;Wrong folder for that agent&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;.agents/skills/&lt;/code&gt; plus a &lt;code&gt;.claude/skills/&lt;/code&gt; symlink&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validator fails on name&lt;/td&gt;
&lt;td&gt;Uppercase, underscores, or folder mismatch&lt;/td&gt;
&lt;td&gt;Rename both to the same lowercase-hyphen name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skill appears but never auto-triggers&lt;/td&gt;
&lt;td&gt;Description lacks trigger words&lt;/td&gt;
&lt;td&gt;Rewrite as "Does X. Use when ..."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fields ignored&lt;/td&gt;
&lt;td&gt;YAML doesn't parse or &lt;code&gt;---&lt;/code&gt; not on line 1&lt;/td&gt;
&lt;td&gt;Fix the YAML; Claude Code still loads the skill with no fields set when it doesn't parse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent says "done" too early&lt;/td&gt;
&lt;td&gt;No validation loop&lt;/td&gt;
&lt;td&gt;Add "run the check again; do not report success until it passes"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;npx skills&lt;/code&gt; crashes&lt;/td&gt;
&lt;td&gt;Node.js older than 22.20&lt;/td&gt;
&lt;td&gt;Upgrade Node (24 LTS works)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skill fires for deploys you didn't ask for&lt;/td&gt;
&lt;td&gt;Auto-invocation on&lt;/td&gt;
&lt;td&gt;&lt;code&gt;disable-model-invocation: true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; Next.js links a &lt;code&gt;vercel-react-view-transitions&lt;/code&gt; skill for adding page animations. My &lt;a href="https://www.fabwaseem.com/blog/react-view-transitions" rel="noopener noreferrer"&gt;React View Transitions guide&lt;/a&gt; covers the same patterns with tested code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; skills, MCP servers and plain APIs overlap. See &lt;a href="https://www.fabwaseem.com/blog/mcp-vs-api" rel="noopener noreferrer"&gt;MCP vs API&lt;/a&gt; for a benchmarked comparison and a decision guide for when an agent needs MCP at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; the AI SDK ships a migration skill for coding agents; my &lt;a href="https://www.fabwaseem.com/blog/ai-sdk-7-migration" rel="noopener noreferrer"&gt;AI SDK 7 migration guide&lt;/a&gt; covers when to use it versus the official codemod, tested on a real v6 app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Agent Skills are folders with a &lt;code&gt;SKILL.md&lt;/code&gt; (YAML &lt;code&gt;name&lt;/code&gt; + &lt;code&gt;description&lt;/code&gt;, then Markdown steps) plus optional &lt;code&gt;scripts/&lt;/code&gt;, &lt;code&gt;references/&lt;/code&gt; and &lt;code&gt;assets/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;One skill works across Claude Code, Cursor, Codex, GitHub Copilot, VS Code and the other clients listed on agentskills.io.&lt;/li&gt;
&lt;li&gt;Put skills in &lt;code&gt;.agents/skills/&lt;/code&gt; and symlink them into &lt;code&gt;.claude/skills/&lt;/code&gt; for Claude Code; &lt;code&gt;npx skills add&lt;/code&gt; does this for you.&lt;/li&gt;
&lt;li&gt;Only about 100 tokens of metadata load per skill until it activates, so keep &lt;code&gt;SKILL.md&lt;/code&gt; under 500 lines and move detail into references.&lt;/li&gt;
&lt;li&gt;The description decides whether a skill triggers: say what it does and "Use when..." with real trigger words.&lt;/li&gt;
&lt;li&gt;Validate with &lt;code&gt;skills-ref validate&lt;/code&gt;, put gotchas and a validation loop in every skill, and review third-party skills before installing.&lt;/li&gt;
&lt;li&gt;Next.js 16.4 ships official skills such as &lt;code&gt;next-bundle-optimizer&lt;/code&gt; and &lt;code&gt;next-cache-components-adoption&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/agent-skills" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>cursor</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Better Auth with Next.js 16: proxy.ts, Prisma 7 and Server Actions (Tested Setup)</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Fri, 23 Oct 2026 07:05:25 +0000</pubDate>
      <link>https://dev.to/fabwaseem/better-auth-with-nextjs-16-proxyts-prisma-7-and-server-actions-tested-setup-6i1</link>
      <guid>https://dev.to/fabwaseem/better-auth-with-nextjs-16-proxyts-prisma-7-and-server-actions-tested-setup-6i1</guid>
      <description>&lt;p&gt;&lt;strong&gt;Better Auth&lt;/strong&gt; is an open-source (MIT) TypeScript authentication library that runs inside your own app and stores users and sessions in your own database. With Next.js 16 you mount one route handler, call &lt;code&gt;auth.api.getSession()&lt;/code&gt; in server components and &lt;code&gt;proxy.ts&lt;/code&gt;, and add the &lt;code&gt;nextCookies()&lt;/code&gt; plugin so server actions can set cookies.&lt;/p&gt;

&lt;p&gt;I built a complete Next.js 16.4.2 app with Better Auth 1.7.10, Prisma 7.10.1 and PostgreSQL 17: email and password sign-up and sign-in through server actions, a protected dashboard, a session check in &lt;code&gt;proxy.ts&lt;/code&gt; and sign-out. Then I tested it with curl and headless Chrome, and counted every database query per page load. The setup works as documented, with two exceptions: one setting that does nothing on Prisma 7 unless you turn on a preview feature, and a React 19 form reset that wipes the email field.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;Install &lt;code&gt;better-auth&lt;/code&gt;, create &lt;code&gt;lib/auth.ts&lt;/code&gt; with &lt;code&gt;prismaAdapter(prisma)&lt;/code&gt; and &lt;code&gt;nextCookies()&lt;/code&gt;, mount &lt;code&gt;toNextJsHandler(auth)&lt;/code&gt; at &lt;code&gt;app/api/auth/[...all]/route.ts&lt;/code&gt;, generate the schema with &lt;code&gt;npx auth@latest generate&lt;/code&gt; and migrate with Prisma. Check the session in every protected page, not only in &lt;code&gt;proxy.ts&lt;/code&gt;. A full proxy check plus a page check cost 4 database queries per load in my test, and the cookie cache cut that to 0.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server config&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/lib/auth.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;betterAuth({ database, emailAndPassword, plugins })&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API route&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/app/api/auth/[...all]/route.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;toNextJsHandler(auth)&lt;/code&gt; serves &lt;code&gt;/api/auth/*&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/lib/auth-client.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;createAuthClient()&lt;/code&gt; from &lt;code&gt;better-auth/react&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route guard&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/proxy.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Redirects signed-out users (optimistic)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real check&lt;/td&gt;
&lt;td&gt;each protected &lt;code&gt;page.tsx&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;auth.api.getSession({ headers: await headers() })&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prisma/schema.prisma&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;user&lt;/code&gt;, &lt;code&gt;session&lt;/code&gt;, &lt;code&gt;account&lt;/code&gt;, &lt;code&gt;verification&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Versions tested on October 17, 2026: &lt;code&gt;better-auth&lt;/code&gt; 1.7.10 (released October 15; 1.8.0-beta.1 is on the &lt;code&gt;beta&lt;/code&gt; tag), Next.js 16.4.2, React 19.3.1, Prisma 7.10.1, Node.js 24.22.0.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Better Auth?
&lt;/h2&gt;

&lt;p&gt;Better Auth is a framework-agnostic authentication library for TypeScript. It handles email and password, social sign-in, sessions, and plugins for 2FA, organizations and passkeys, all inside your own server. Users, sessions and accounts live in your database through an adapter for Prisma, Drizzle, Kysely or a direct connection. There is no hosted service to call.&lt;/p&gt;

&lt;p&gt;It is MIT licensed, had about 30,000 GitHub stars on &lt;a href="https://github.com/better-auth/better-auth" rel="noopener noreferrer"&gt;its repository&lt;/a&gt; when I checked, and has shipped stable 1.x releases since 1.0.0 in November 2024. Its own security docs say passwords are hashed with &lt;code&gt;scrypt&lt;/code&gt; by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I set up Better Auth in Next.js 16?
&lt;/h2&gt;

&lt;p&gt;Install &lt;code&gt;better-auth&lt;/code&gt;, add &lt;code&gt;BETTER_AUTH_SECRET&lt;/code&gt; and &lt;code&gt;BETTER_AUTH_URL&lt;/code&gt; to &lt;code&gt;.env&lt;/code&gt;, create a &lt;code&gt;betterAuth()&lt;/code&gt; instance with a database adapter, mount the handler at &lt;code&gt;app/api/auth/[...all]/route.ts&lt;/code&gt;, then generate and migrate the schema. With Prisma 7 that takes six files and two CLI commands. Each step below is the exact code I ran.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4anmp9xwtic993ee72n.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc4anmp9xwtic993ee72n.jpg" alt="Better Auth request flow in Next.js 16: browser to proxy.ts optimistic check, page.tsx getSession, Better Auth API route and Prisma to PostgreSQL user, session, account and verification tables" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Install packages and set the secret
&lt;/h3&gt;



&lt;p&gt;```bash title="Terminal"&lt;br&gt;
npm i better-auth @prisma/client @prisma/adapter-pg&lt;br&gt;
npm i -D prisma dotenv&lt;br&gt;
npx prisma init --datasource-provider postgresql --output ../src/generated/prisma&lt;/p&gt;

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




```bash title=".env"
DATABASE_URL="postgresql://demo:demo@127.0.0.1:5432/badb"
BETTER_AUTH_SECRET="at-least-32-random-characters"   # openssl rand -base64 32
BETTER_AUTH_URL="http://localhost:3000"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;better-auth&lt;/code&gt; 1.7.10 already depends on &lt;code&gt;@better-auth/prisma-adapter&lt;/code&gt;, so &lt;code&gt;import { prismaAdapter } from "better-auth/adapters/prisma"&lt;/code&gt; works without installing the adapter separately.&lt;/p&gt;

&lt;p&gt;Two things surprised me with Prisma 7.10.1. &lt;code&gt;prisma init&lt;/code&gt; wrote &lt;code&gt;prisma7.config.ts&lt;/code&gt;, not &lt;code&gt;prisma.config.ts&lt;/code&gt;. It also installed Prisma agent skills into &lt;code&gt;.agents/&lt;/code&gt;, &lt;code&gt;.claude/&lt;/code&gt; and &lt;code&gt;.windsurf/&lt;/code&gt;, so review that before you commit. Also, if your shell already exports &lt;code&gt;DATABASE_URL&lt;/code&gt;, dotenv will not override it. My first &lt;code&gt;migrate dev&lt;/code&gt; targeted the wrong database and asked to reset it. Read the target in the prompt before you answer yes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Create the Prisma client and the auth instance
&lt;/h3&gt;



&lt;p&gt;```ts title="src/lib/prisma.ts"&lt;br&gt;
import { PrismaPg } from "@prisma/adapter-pg";&lt;br&gt;
import { PrismaClient } from "@/generated/prisma/client";&lt;/p&gt;

&lt;p&gt;const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };&lt;/p&gt;

&lt;p&gt;export const prisma =&lt;br&gt;
  globalForPrisma.prisma ??&lt;br&gt;
  new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL! }) });&lt;/p&gt;

&lt;p&gt;if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;&lt;/p&gt;

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




```ts title="src/lib/auth.ts"
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import { prisma } from "./prisma";

export const auth = betterAuth({
  database: prismaAdapter(prisma, { provider: "postgresql" }),
  emailAndPassword: { enabled: true },
  plugins: [nextCookies()], // keep this last
});

export type Session = typeof auth.$Infer.Session;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Mount the API route and create the client
&lt;/h3&gt;



&lt;p&gt;```ts title="src/app/api/auth/[...all]/route.ts"&lt;br&gt;
import { toNextJsHandler } from "better-auth/next-js";&lt;br&gt;
import { auth } from "@/lib/auth";&lt;/p&gt;

&lt;p&gt;export const { GET, POST } = toNextJsHandler(auth);&lt;/p&gt;

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




```ts title="src/lib/auth-client.ts"
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All Better Auth endpoints now live under &lt;code&gt;/api/auth&lt;/code&gt;, for example &lt;code&gt;POST /api/auth/sign-up/email&lt;/code&gt; and &lt;code&gt;GET /api/auth/get-session&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Generate the schema and migrate
&lt;/h3&gt;



&lt;p&gt;```bash title="Terminal"&lt;br&gt;
npx prisma generate&lt;br&gt;
npx auth@latest generate --config src/lib/auth.ts --yes&lt;br&gt;
npx prisma migrate dev --name add-better-auth&lt;/p&gt;

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


Run `prisma generate` first. With no generated client, `auth generate` could not load `src/lib/auth.ts` and hung for three minutes until I killed it. Once the client existed, it finished in seconds and wrote four models, `User`, `Session`, `Account` and `Verification`, mapped to the tables `user`, `session`, `account` and `verification`. The CLI only writes the schema. As the [Better Auth Prisma adapter docs](https://www.better-auth.com/docs/adapters/prisma) say, migrations are left to Prisma.

Better Auth 1.7 also checks the schema at startup. Before the migration ran, it logged `Prisma schema mismatch` and listed the missing tables. That is a useful early warning in CI.

## How do I sign in with a server action?

Call `auth.api.signInEmail()` or `auth.api.signUpEmail()` inside a `"use server"` function, and keep the `nextCookies()` plugin in your config. Without it the server action succeeds but sets no cookie. In my test the user stayed signed out and was redirected back to the sign-in page. With it, the session cookie was set and the redirect to `/dashboard` worked.



```ts title="src/app/actions.ts"
"use server";

import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { APIError } from "better-auth/api";
import { auth } from "@/lib/auth";

export type SignInState = { error: string; email: string } | null;

export async function signIn(_: SignInState, form: FormData): Promise&amp;lt;SignInState&amp;gt; {
  const email = String(form.get("email"));
  try {
    await auth.api.signInEmail({ body: { email, password: String(form.get("password")) } });
  } catch (e) {
    // Return the email: React 19 resets the form after an action.
    if (e instanceof APIError) return { error: e.message, email };
    throw e;
  }
  redirect("/dashboard");
}

export async function signOut() {
  await auth.api.signOut({ headers: await headers() });
  redirect("/sign-in");
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;```tsx title="src/app/sign-in/page.tsx (sign-in form)"&lt;br&gt;
"use client";&lt;/p&gt;

&lt;p&gt;import { useActionState } from "react";&lt;br&gt;
import { signIn } from "../actions";&lt;/p&gt;

&lt;p&gt;export default function SignInPage() {&lt;br&gt;
  const [state, action, pending] = useActionState(signIn, null);&lt;br&gt;
  return (&lt;br&gt;
    &lt;/p&gt;
&lt;br&gt;
      &lt;br&gt;
      &lt;br&gt;
      Sign in&lt;br&gt;
      {state?.error &amp;amp;&amp;amp; &lt;p&gt;{state.error}&lt;/p&gt;}&lt;br&gt;
    &lt;br&gt;
  );&lt;br&gt;
}

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


Why return the email? React 19 resets a form after its action finishes. In my Chrome test, a wrong password showed "Invalid email or password" and the email field was empty. After returning the email from the action and using it as `defaultValue`, the field kept its value. See [useActionState](https://react.dev/reference/react/useActionState) for the pattern. Keep `redirect()` outside the `try` block, because it works by throwing.

## How do I protect routes with proxy.ts?

Next.js 16 renamed `middleware.ts` to `proxy.ts`, and it runs on Node.js, so `auth.api.getSession()` works there. Use the proxy only to redirect signed-out users early. Then check the session again in every protected page or server action, because the proxy can be skipped by a matcher mistake and it is not a security boundary.



```ts title="src/proxy.ts"
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";

export async function proxy(request: NextRequest) {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) {
    return NextResponse.redirect(new URL("/sign-in", request.url));
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*"],
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;```tsx title="src/app/dashboard/page.tsx"&lt;br&gt;
import { headers } from "next/headers";&lt;br&gt;
import { redirect } from "next/navigation";&lt;br&gt;
import { auth } from "@/lib/auth";&lt;br&gt;
import { signOut } from "../actions";&lt;/p&gt;

&lt;p&gt;export default async function DashboardPage() {&lt;br&gt;
  // The proxy only redirects. This check is the one that protects the page.&lt;br&gt;
  const session = await auth.api.getSession({ headers: await headers() });&lt;br&gt;
  if (!session) redirect("/sign-in");&lt;/p&gt;

&lt;p&gt;return (&lt;br&gt;
    &lt;/p&gt;
&lt;br&gt;
      &lt;h1&gt;Welcome, {session.user.name}&lt;/h1&gt;
&lt;br&gt;
      &lt;br&gt;
        Sign out&lt;br&gt;
      &lt;br&gt;
    &lt;br&gt;
  );&lt;br&gt;
}

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


For a proxy with no database call, the [Better Auth Next.js docs](https://www.better-auth.com/docs/integrations/next) offer `getSessionCookie(request)` from `better-auth/cookies`, and label it "THIS IS NOT SECURE!" I tested why. A request with a made-up cookie, `better-auth.session_token=forged.value`, passed the cookie-only proxy. The page's own `getSession()` call then redirected it to `/sign-in`. With the full check in `proxy.ts`, the forged cookie was redirected at the proxy.

Next.js documents the rename and the codemod (`npx @next/codemod@canary middleware-to-proxy .`) in its [proxy.ts reference](https://nextjs.org/docs/app/api-reference/file-conventions/proxy).

## What did my tests show?

Everything in the core flow worked on the first production build (`next build` then `next start`). I tested with curl against the HTTP API and with headless Chrome 155 through the real forms and server actions:

| Test | Result |
| --- | --- |
| Visit `/dashboard` signed out | 307 to `/sign-in` |
| Sign up through the form (server action) | Redirect to `/dashboard`, "Welcome, UI Tester" |
| Same, without `nextCookies()` | No cookie set, user stays signed out |
| Session cookie | `better-auth.session_token`, `HttpOnly`, `SameSite=Lax`, `Max-Age=604800` (7 days) |
| Wrong password | 401 `INVALID_EMAIL_OR_PASSWORD` |
| Sign-in with `Origin: https://evil.example` | 403 `INVALID_ORIGIN` |
| Forged cookie, full proxy check | 307 to `/sign-in` |
| Forged cookie, cookie-only proxy | Passes proxy, page redirects to `/sign-in` |
| Sign out, then visit `/dashboard` | 307 to `/sign-in` |

Two notes. A curl request with no `Origin` header at all was accepted. That matches the [Better Auth security docs](https://www.better-auth.com/docs/reference/security), which allow requests that carry neither an Origin nor Fetch Metadata headers so that server-to-server and native clients keep working. Also, my cookie had no `Secure` flag over `http://localhost`. The [cookies docs](https://www.better-auth.com/docs/concepts/cookies) say cookies are secure in production mode, and `advanced.useSecureCookies: true` forces it.

## How many database queries does Better Auth make per request?

Each `getSession()` call made 2 queries on Prisma 7: one for the session by token and one for the user. A full check in `proxy.ts` plus a check in the page therefore cost 4 queries per page load. The cookie cache brought that to 0, and a cookie-only proxy to 2. I counted with PostgreSQL statement logging, over two warm dashboard loads per setup.

![Database queries per protected page load with Better Auth and Prisma 7: full proxy and page check 4, joins true without relationJoins 4, with relationJoins 2, cookie-only proxy 2, cookie cache 0](https://res.cloudinary.com/wasicloudinary/image/upload/v1792250133/fabwaseem/blog/better-auth-nextjs/diagram-queries.jpg)

| Setup (proxy + page check) | Queries per load | Median load time (local DB) |
| --- | --- | --- |
| Full `getSession()` in proxy and page | 4 | 7.3 ms |
| + `advanced.database.joins: true` | **4** (no change) | not measured |
| + `joins: true` and Prisma `relationJoins` preview | 2 | 6.8 ms |
| + `session.cookieCache` (5 min) | 0 | 5.9 ms |
| Cookie-only proxy, full check in page | 2 | 6.0 ms |

Times are the median of 300 requests from 2 runs against PostgreSQL on the same machine, so each query cost well under a millisecond. With a database in another region, every query is a network round trip, so the query count matters more than these timings suggest.

### Why doesn't `joins: true` reduce queries on Prisma 7?

Because Prisma 7 still runs `include` as separate queries unless you enable its `relationJoins` preview. With `joins: true` alone, the PostgreSQL log still showed `SELECT ... FROM "session"` followed by `SELECT ... FROM "user" WHERE "id" IN ($1)`. After adding `previewFeatures = ["relationJoins"]` and running `prisma generate`, getSession became one `LEFT JOIN LATERAL` query.



```prisma title="prisma/schema.prisma"
generator client {
  provider        = "prisma-client"
  output          = "../src/generated/prisma"
  previewFeatures = ["relationJoins"]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;p&gt;```ts title="src/lib/auth.ts"&lt;br&gt;
export const auth = betterAuth({&lt;br&gt;
  // ...&lt;br&gt;
  advanced: { database: { joins: true } },&lt;br&gt;
});&lt;/p&gt;

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


The Better Auth docs promise "2x to 3x performance improvements" from joins, depending on database latency, without mentioning the Prisma preview flag. Prisma's [relation queries docs](https://www.prisma.io/docs/orm/prisma-client/queries/relation-queries) list `relationJoins` as Preview. One caution: in my [Drizzle vs Prisma benchmark](https://www.fabwaseem.com/blog/drizzle-vs-prisma), `relationJoins` made paginated `include` queries with a deep `OFFSET` much slower. The flag applies to the whole client, so measure your other queries after enabling it.

### Should I enable the cookie cache?

Enable it if your sessions rarely change and you can accept a short revocation delay. With `cookieCache: { enabled: true, maxAge: 5 * 60 }`, Better Auth stores the session in a signed `better-auth.session_data` cookie and skips the database. The [session management docs](https://www.better-auth.com/docs/concepts/session-management) warn that a revoked session "may remain active on other devices until the cookie cache expires."



```ts title="src/lib/auth.ts"
export const auth = betterAuth({
  // ...
  session: {
    cookieCache: { enabled: true, maxAge: 5 * 60 }, // seconds
  },
});
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One catch I hit. With a cookie-only proxy, the cache cookie never got written, because the only &lt;code&gt;getSession()&lt;/code&gt; call ran in a server component and React Server Components cannot set cookies. The query count stayed at 2. Once &lt;code&gt;session_data&lt;/code&gt; was set by a route handler (&lt;code&gt;/api/auth/get-session&lt;/code&gt;) or by the full check in &lt;code&gt;proxy.ts&lt;/code&gt;, page loads dropped to 0 queries. The &lt;a href="https://www.better-auth.com/docs/integrations/next" rel="noopener noreferrer"&gt;Better Auth Next.js docs&lt;/a&gt; warn about this: "As RSCs cannot set cookies, the cookie cache will not be refreshed until the server is interacted with from the client via Server Actions or Route Handlers."&lt;/p&gt;

&lt;h2&gt;
  
  
  Better Auth vs Clerk: which should I use?
&lt;/h2&gt;

&lt;p&gt;Choose Better Auth when you want auth inside your own app and database, no per-user vendor pricing and full control of the schema and UI. Choose Clerk when you want a hosted service with prebuilt sign-in components, a user dashboard and managed infrastructure. You trade control and data ownership for less code to run.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Better Auth&lt;/th&gt;
&lt;th&gt;Clerk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it is&lt;/td&gt;
&lt;td&gt;Library inside your app&lt;/td&gt;
&lt;td&gt;Hosted authentication service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where users live&lt;/td&gt;
&lt;td&gt;Your database (Prisma, Drizzle, Kysely, SQL)&lt;/td&gt;
&lt;td&gt;Clerk's infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License&lt;/td&gt;
&lt;td&gt;MIT, open source&lt;/td&gt;
&lt;td&gt;Commercial SaaS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;You build it (or use community components)&lt;/td&gt;
&lt;td&gt;Prebuilt components&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosting&lt;/td&gt;
&lt;td&gt;Yes, runs wherever Next.js runs&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You operate&lt;/td&gt;
&lt;td&gt;Database, secrets, email delivery, upgrades&lt;/td&gt;
&lt;td&gt;Integration only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Pricing and limits change, so check &lt;a href="https://clerk.com/docs" rel="noopener noreferrer"&gt;Clerk's docs&lt;/a&gt; for current plans. If you self-host Next.js, Better Auth fits that setup. My &lt;a href="https://www.fabwaseem.com/blog/nextjs-ai-starter-kit" rel="noopener noreferrer"&gt;Next.js AI starter kit&lt;/a&gt; uses Better Auth 1.7.10 with Drizzle for that reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can I use Better Auth with Prisma 8 or Drizzle?
&lt;/h2&gt;

&lt;p&gt;Drizzle yes, Prisma 8 not yet. Better Auth 1.7.10's peer dependencies accept &lt;code&gt;drizzle-orm&lt;/code&gt; 0.45.2 or newer, including the 1.0 release candidates, and &lt;code&gt;@prisma/client&lt;/code&gt; 5, 6 or 7. Prisma 8 replaces &lt;code&gt;@prisma/client&lt;/code&gt; with a new runtime package, so the Prisma adapter does not cover it yet.&lt;/p&gt;

&lt;p&gt;If you are starting a new project, my &lt;a href="https://www.fabwaseem.com/blog/prisma-8" rel="noopener noreferrer"&gt;Prisma 8 guide&lt;/a&gt; explains why to pin Prisma 7 for now. The Drizzle adapter is &lt;code&gt;drizzleAdapter(db, { provider: "pg" })&lt;/code&gt; from &lt;code&gt;better-auth/adapters/drizzle&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Better Auth mistakes in Next.js
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Relying on the proxy alone.&lt;/strong&gt; Check the session in every protected page, route handler and server action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting &lt;code&gt;nextCookies()&lt;/code&gt;.&lt;/strong&gt; Sign-in through a server action silently sets no cookie. Keep it last in &lt;code&gt;plugins&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Putting &lt;code&gt;redirect()&lt;/code&gt; inside &lt;code&gt;try&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;redirect&lt;/code&gt; throws, so your &lt;code&gt;catch&lt;/code&gt; swallows it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running &lt;code&gt;auth generate&lt;/code&gt; before &lt;code&gt;prisma generate&lt;/code&gt;.&lt;/strong&gt; It cannot import a client that does not exist yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expecting &lt;code&gt;joins: true&lt;/code&gt; to work on Prisma 7 by itself.&lt;/strong&gt; It needs the &lt;code&gt;relationJoins&lt;/code&gt; preview.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cookie-only proxy plus the cookie cache.&lt;/strong&gt; RSCs cannot write the cache cookie, so it stays empty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard-coding &lt;code&gt;BETTER_AUTH_SECRET&lt;/code&gt;.&lt;/strong&gt; Generate 32+ random bytes and keep it in your deployment's environment, not in git.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the wider question of when to use server actions and when to use route handlers, see my &lt;a href="https://www.fabwaseem.com/blog/server-actions-vs-route-handlers" rel="noopener noreferrer"&gt;server actions vs route handlers guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Better Auth 1.7.10 works with Next.js 16.4 and Prisma 7: one route handler, an auth instance with prismaAdapter, and nextCookies() for server actions.&lt;/li&gt;
&lt;li&gt;Generate the Prisma client before running npx auth generate, then migrate with Prisma; the Better Auth CLI writes the schema but does not migrate.&lt;/li&gt;
&lt;li&gt;proxy.ts can run a full getSession() on Node.js, but always check the session again in each page; a forged cookie passed the cookie-only proxy in my test.&lt;/li&gt;
&lt;li&gt;A full proxy check plus a page check cost 4 database queries per load on Prisma 7; the cookie cache cut it to 0.&lt;/li&gt;
&lt;li&gt;advanced.database.joins only reduced queries after enabling Prisma's relationJoins preview.&lt;/li&gt;
&lt;li&gt;React 19 resets forms after an action, so return the email from a failed sign-in action to keep it in the field.&lt;/li&gt;
&lt;li&gt;Better Auth supports Drizzle 0.45 and 1.0 RC and Prisma 5 to 7, but not Prisma 8 yet.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/better-auth-nextjs" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>auth</category>
      <category>typescript</category>
      <category>prisma</category>
    </item>
    <item>
      <title>AI SDK 7 Migration: Upgrading from AI SDK 6 (What the Codemod Misses)</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Fri, 23 Oct 2026 07:05:03 +0000</pubDate>
      <link>https://dev.to/fabwaseem/ai-sdk-7-migration-upgrading-from-ai-sdk-6-what-the-codemod-misses-26me</link>
      <guid>https://dev.to/fabwaseem/ai-sdk-7-migration-upgrading-from-ai-sdk-6-what-the-codemod-misses-26me</guid>
      <description>&lt;p&gt;An &lt;strong&gt;AI SDK 7 migration&lt;/strong&gt; looks like a rename job: &lt;code&gt;system&lt;/code&gt; becomes &lt;code&gt;instructions&lt;/code&gt;, &lt;code&gt;onFinish&lt;/code&gt; becomes &lt;code&gt;onEnd&lt;/code&gt;, and there's a codemod for most of it. I upgraded a real AI SDK 6 app to see what's actually left after the codemod. The codemod ran in 20 seconds and fixed 15 lines, but it fixed only 1 of the 9 tests that broke. The rest were behaviour changes that TypeScript doesn't flag at all, including token usage that silently doubled and OpenTelemetry traces that silently stopped.&lt;/p&gt;

&lt;p&gt;Here's the full upgrade path from AI SDK 6 to 7, with every number measured on &lt;code&gt;ai&lt;/code&gt; 6.0.309 → 7.0.143.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR: how do I upgrade to AI SDK 7?
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Make sure you're on Node.js 22 or later (24 LTS is better). AI SDK 7 drops Node 18 and 20.&lt;/li&gt;
&lt;li&gt;Commit, then upgrade: &lt;code&gt;npm i ai@7 @ai-sdk/react@4 @ai-sdk/openai@4&lt;/code&gt; (and your other providers).&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;npx @ai-sdk/codemod v7&lt;/code&gt; from the project root.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;tsc --noEmit&lt;/code&gt; and fix what's left, mainly tool context.&lt;/li&gt;
&lt;li&gt;Review the behaviour changes by hand: &lt;code&gt;usage&lt;/code&gt; and &lt;code&gt;toolCalls&lt;/code&gt; now cover all steps, &lt;code&gt;step.response.messages&lt;/code&gt; is per step, &lt;code&gt;onChunk&lt;/code&gt; gets every stream part, and system messages inside &lt;code&gt;messages&lt;/code&gt; now throw.&lt;/li&gt;
&lt;li&gt;If you use OpenTelemetry, install &lt;code&gt;@ai-sdk/otel&lt;/code&gt; and call &lt;code&gt;registerTelemetry()&lt;/code&gt;, or your traces stop.&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;TypeScript errors&lt;/th&gt;
&lt;th&gt;Failing tests (of 13)&lt;/th&gt;
&lt;th&gt;Lines changed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;v6 baseline (&lt;code&gt;ai&lt;/code&gt; 6.0.309)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bump to &lt;code&gt;ai&lt;/code&gt; 7.0.143, no code changes&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After &lt;code&gt;npx @ai-sdk/codemod v7&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After manual fixes&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;+18 more&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What's new in AI SDK 7?
&lt;/h2&gt;

&lt;p&gt;AI SDK 7.0 shipped on June 25, 2026, and the current release is &lt;code&gt;ai&lt;/code&gt; 7.0.143. The headline changes in the &lt;a href="https://ai-sdk.dev/docs/migration-guides/migration-guide-7-0" rel="noopener noreferrer"&gt;official migration guide&lt;/a&gt; are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node.js 22+ and ESM-only packages.&lt;/strong&gt; No more CommonJS builds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Renames out of &lt;code&gt;experimental_&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;telemetry&lt;/code&gt;, &lt;code&gt;include&lt;/code&gt;, &lt;code&gt;onStart&lt;/code&gt;, &lt;code&gt;onStepStart&lt;/code&gt;, &lt;code&gt;transcribe&lt;/code&gt;, &lt;code&gt;generateSpeech&lt;/code&gt;, and tool &lt;code&gt;context&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New names for old concepts&lt;/strong&gt;: &lt;code&gt;system&lt;/code&gt; → &lt;code&gt;instructions&lt;/code&gt;, &lt;code&gt;onFinish&lt;/code&gt; → &lt;code&gt;onEnd&lt;/code&gt;, &lt;code&gt;onStepFinish&lt;/code&gt; → &lt;code&gt;onStepEnd&lt;/code&gt;, &lt;code&gt;fullStream&lt;/code&gt; → &lt;code&gt;stream&lt;/code&gt;, &lt;code&gt;stepCountIs&lt;/code&gt; → &lt;code&gt;isStepCount&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Results cover every step.&lt;/strong&gt; &lt;code&gt;usage&lt;/code&gt;, &lt;code&gt;toolCalls&lt;/code&gt;, &lt;code&gt;toolResults&lt;/code&gt;, &lt;code&gt;content&lt;/code&gt; and friends are accumulated across a multi-step tool loop, and the old final-step values move to &lt;code&gt;result.finalStep&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typed, per-tool context&lt;/strong&gt; with &lt;code&gt;contextSchema&lt;/code&gt; and &lt;code&gt;toolsContext&lt;/code&gt;, separate from a shared &lt;code&gt;runtimeContext&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telemetry as a plug-in.&lt;/strong&gt; OpenTelemetry lives in &lt;code&gt;@ai-sdk/otel&lt;/code&gt;, and telemetry is on by default once you register an integration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safer defaults&lt;/strong&gt;: system messages in &lt;code&gt;messages&lt;/code&gt; are rejected, request bodies aren't kept in results, and MCP HTTP transports no longer follow redirects.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most renames keep the old name as a deprecated alias, so your code keeps compiling. That's convenient, and it's also why a type check alone won't tell you whether the migration is done.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested the upgrade
&lt;/h2&gt;

&lt;p&gt;I wrote a small but realistic AI SDK 6 app, the kind of code you'd find in a Next.js chat product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;strong&gt;tool loop&lt;/strong&gt; with &lt;code&gt;generateText&lt;/code&gt;, a &lt;code&gt;system&lt;/code&gt; prompt, a weather tool reading &lt;code&gt;experimental_context&lt;/code&gt;, &lt;code&gt;stopWhen: stepCountIs(3)&lt;/code&gt;, &lt;code&gt;onStepFinish&lt;/code&gt; and &lt;code&gt;onFinish&lt;/code&gt; callbacks logging token usage, and &lt;code&gt;experimental_telemetry&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;streaming chat route&lt;/strong&gt; using &lt;code&gt;streamText&lt;/code&gt;, &lt;code&gt;convertToModelMessages&lt;/code&gt; and &lt;code&gt;toUIMessageStreamResponse()&lt;/code&gt;, plus a &lt;code&gt;fullStream&lt;/code&gt; consumer with an &lt;code&gt;onChunk&lt;/code&gt; handler&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;structured output&lt;/strong&gt; with &lt;code&gt;experimental_output: Output.object(...)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;stored chats&lt;/strong&gt; whose first message is &lt;code&gt;{ role: 'system' }&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a small &lt;strong&gt;CommonJS script&lt;/strong&gt; that does &lt;code&gt;require('ai')&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I wrote 13 tests with &lt;code&gt;node:test&lt;/code&gt; and the SDK's own &lt;code&gt;MockLanguageModelV3&lt;/code&gt;, so every run is offline and deterministic. Each test pins down one behaviour the app relies on: the tool gets its context, &lt;code&gt;usage&lt;/code&gt; is the final step's, the last step's messages hold the whole exchange, and so on. All 13 passed on v6 with zero TypeScript errors.&lt;/p&gt;

&lt;p&gt;Versions: &lt;code&gt;ai&lt;/code&gt; 6.0.309 → 7.0.143, &lt;code&gt;@ai-sdk/openai&lt;/code&gt; 3.0.130 → 4.0.96, &lt;code&gt;@ai-sdk/react&lt;/code&gt; 3.0.312 → 4.0.147, &lt;code&gt;@ai-sdk/codemod&lt;/code&gt; 4.0.3, TypeScript 5.9.3, Node 24.22.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: bump the packages (6 type errors, 9 failing tests)
&lt;/h2&gt;

&lt;p&gt;Upgrading the packages without touching code gave 6 TypeScript errors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;experimental_context&lt;/code&gt; is gone from both the &lt;code&gt;generateText&lt;/code&gt; options and the tool's &lt;code&gt;execute&lt;/code&gt; options (2 errors)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;experimental_output&lt;/code&gt; is gone as an option and as a result property (2 errors)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;usage.cachedInputTokens&lt;/code&gt; no longer exists&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;ToolCallOptions&lt;/code&gt; type is no longer exported&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And 9 of 13 tests failed. The scariest failure was one that &lt;em&gt;didn't&lt;/em&gt; look like a failure: with &lt;code&gt;experimental_context&lt;/code&gt; gone, the tool threw &lt;code&gt;Cannot destructure property 'units' of 'experimental_context' as it is undefined&lt;/code&gt;. The SDK turned that into an &lt;code&gt;error-text&lt;/code&gt; tool result, sent it back to the model, and my mock model still produced a normal answer. The "tool loop works" test passed. A real model would probably apologise or guess, and you'd find out from users.&lt;/p&gt;

&lt;p&gt;The CommonJS script, on the other hand, kept working. AI SDK 7 ships only ESM, but Node 24 can &lt;code&gt;require()&lt;/code&gt; ES modules natively, so &lt;code&gt;require('ai')&lt;/code&gt; still returned the functions. On older Node versions without &lt;code&gt;require(esm)&lt;/code&gt; support it will fail, but those versions aren't supported by AI SDK 7 anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: run the codemod (2 type errors, 8 failing tests)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @ai-sdk/codemod v7 src &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It took 19.8 seconds and changed 15 lines in 4 files:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;system&lt;/code&gt; → &lt;code&gt;instructions&lt;/code&gt; (2 places) and &lt;code&gt;stepCountIs&lt;/code&gt; → &lt;code&gt;isStepCount&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;onStepFinish&lt;/code&gt; → &lt;code&gt;onStepEnd&lt;/code&gt;, &lt;code&gt;onFinish&lt;/code&gt; → &lt;code&gt;onEnd&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;experimental_telemetry&lt;/code&gt; → &lt;code&gt;telemetry&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;usage.cachedInputTokens&lt;/code&gt; → &lt;code&gt;usage.inputTokenDetails.cacheReadTokens&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;experimental_output&lt;/code&gt; → &lt;code&gt;output&lt;/code&gt; (option and result)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fullStream&lt;/code&gt; → &lt;code&gt;stream&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ToolCallOptions&lt;/code&gt; → &lt;code&gt;ToolExecutionOptions&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;{ experimental_context }&lt;/code&gt; → &lt;code&gt;{ context: experimental_context }&lt;/code&gt; inside the tool's &lt;code&gt;execute&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those are all correct, mechanical renames. What it couldn't do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tool context at the call site.&lt;/strong&gt; It left &lt;code&gt;experimental_context: { units: 'c' }&lt;/code&gt; in the &lt;code&gt;generateText&lt;/code&gt; call, which is now a type error. The v7 design needs a &lt;code&gt;contextSchema&lt;/code&gt; on the tool and a &lt;code&gt;toolsContext&lt;/code&gt; object keyed by tool name, which isn't a rename.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The new generic.&lt;/strong&gt; It swapped in &lt;code&gt;ToolExecutionOptions&lt;/code&gt;, which now requires a type argument, so the codemod itself introduced a new type error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any behaviour change.&lt;/strong&gt; It kept &lt;code&gt;telemetry: { isEnabled: true }&lt;/code&gt; and kept reading &lt;code&gt;result.usage&lt;/code&gt; and &lt;code&gt;result.toolCalls&lt;/code&gt; exactly as before, now with different meanings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only test it fixed was structured output. Eight still failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the behaviour changes you have to fix by hand
&lt;/h2&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffxyyl0eywstmd5ma86qc.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffxyyl0eywstmd5ma86qc.jpg" alt="AI SDK 6 to 7 migration steps: TypeScript errors and failing tests after the version bump, the codemod and manual fixes" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These are the changes that broke tests even after the codemod, with the fix I used for each.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool context.&lt;/strong&gt; Declare what each tool needs and pass it per tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;weather&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;contextSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;units&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;c&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;f&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;execute&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;city&lt;/span&gt; &lt;span class="p"&gt;},&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;units&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getWeather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;units&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="nf"&gt;generateText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;weather&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;toolsContext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;weather&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;units&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;c&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;// ...&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bonus is that &lt;code&gt;context&lt;/code&gt; is now typed, so the &lt;code&gt;as { units: ... }&lt;/code&gt; cast goes away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Usage now covers all steps.&lt;/strong&gt; In my two-step tool loop, &lt;code&gt;result.usage.totalTokens&lt;/code&gt; went from 15 (final step only) to 30 (both steps), and cached input tokens went from 4 to 8. &lt;code&gt;totalUsage&lt;/code&gt; is deprecated. If your billing, rate limits or logs read &lt;code&gt;usage&lt;/code&gt;, decide which number you want. For the old meaning, read &lt;code&gt;result.finalStep.usage&lt;/code&gt;. The same applies inside &lt;code&gt;onEnd&lt;/code&gt;: &lt;code&gt;event.usage&lt;/code&gt; is now all steps and &lt;code&gt;event.finalStep.usage&lt;/code&gt; is the last step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;toolCalls&lt;/code&gt; and friends now cover all steps.&lt;/strong&gt; &lt;code&gt;result.toolCalls.length&lt;/code&gt; went from 0 to 1, because the tool call happened in step 1, not the final step. Same for &lt;code&gt;toolResults&lt;/code&gt;, &lt;code&gt;content&lt;/code&gt;, &lt;code&gt;files&lt;/code&gt;, &lt;code&gt;sources&lt;/code&gt; and &lt;code&gt;warnings&lt;/code&gt;. Use &lt;code&gt;result.finalStep.toolCalls&lt;/code&gt; for the old behaviour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;step.response.messages&lt;/code&gt; is per step.&lt;/strong&gt; In v6 the last step's &lt;code&gt;response.messages&lt;/code&gt; held the whole exchange (3 messages: tool call, tool result, answer). In v7 it held 1. If you persist the conversation from it, switch to &lt;code&gt;result.responseMessages&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;onChunk&lt;/code&gt; receives every stream part.&lt;/strong&gt; My handler went from seeing 2 chunks (&lt;code&gt;text-delta&lt;/code&gt; ×2) to 8, including &lt;code&gt;start&lt;/code&gt;, &lt;code&gt;start-step&lt;/code&gt;, &lt;code&gt;text-start&lt;/code&gt;, &lt;code&gt;text-end&lt;/code&gt;, &lt;code&gt;finish-step&lt;/code&gt; and &lt;code&gt;finish&lt;/code&gt;. Guard on &lt;code&gt;chunk.type&lt;/code&gt; before using it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;System messages in &lt;code&gt;messages&lt;/code&gt; throw.&lt;/strong&gt; v6 printed a warning; v7 throws &lt;code&gt;AI_InvalidPromptError: System messages are not allowed in the prompt or messages fields&lt;/code&gt;. That breaks old stored chats. I moved them into &lt;code&gt;instructions&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;instructions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;'&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;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system&lt;/span&gt;&lt;span class="dl"&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;generateText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;instructions&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only use &lt;code&gt;allowSystemInMessages: true&lt;/code&gt; if every stored message came from your own server code, because a user-supplied system message is a prompt injection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deprecated stream helpers.&lt;/strong&gt; &lt;code&gt;result.toUIMessageStreamResponse()&lt;/code&gt; still works but is deprecated. The stateless replacement is &lt;code&gt;createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) })&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With these 18 extra lines across the same 4 files, I was back to 0 TypeScript errors, 13 of 13 tests passing and no deprecation warnings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does OpenTelemetry still work after upgrading to AI SDK 7?
&lt;/h2&gt;

&lt;p&gt;Not by itself. This was the most silent break I found, and none of my tests caught it, so I tested it separately with an in-memory OpenTelemetry exporter.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;v6&lt;/strong&gt; with &lt;code&gt;experimental_telemetry: { isEnabled: true }&lt;/code&gt; recorded 2 spans: &lt;code&gt;ai.generateText&lt;/code&gt; and &lt;code&gt;ai.generateText.doGenerate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v7&lt;/strong&gt;, same code, recorded &lt;strong&gt;0 spans&lt;/strong&gt;. No error, no warning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;v7&lt;/strong&gt; with &lt;code&gt;@ai-sdk/otel&lt;/code&gt; 1.0.143 and &lt;code&gt;registerTelemetry(new OpenTelemetry())&lt;/code&gt; at startup recorded 3 spans, with new names: &lt;code&gt;invoke_agent mock-model-id&lt;/code&gt;, &lt;code&gt;chat mock-model-id&lt;/code&gt; and &lt;code&gt;step 1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So you need the new package &lt;em&gt;and&lt;/em&gt; you need to update any dashboard, alert or query that filters on the old &lt;code&gt;ai.generateText&lt;/code&gt; span names. For Next.js, register it in &lt;code&gt;instrumentation.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;registerTelemetry&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;ai&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;OpenTelemetry&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;@ai-sdk/otel&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;registerTelemetry&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;OpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once an integration is registered, every call is traced by default, so you can delete &lt;code&gt;isEnabled: true&lt;/code&gt; and use &lt;code&gt;isEnabled: false&lt;/code&gt; to opt out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which AI SDK 7 changes does the codemod catch?
&lt;/h2&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz3jfoe7ugtihs115f9jf.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz3jfoe7ugtihs115f9jf.jpg" alt="Which AI SDK 7 changes cause a type error, are fixed by the codemod, or are only caught by tests" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The pattern is clear. Renames are covered: either the old name still works as an alias, or TypeScript flags it and the codemod fixes it. Semantic changes are not: all-steps results, per-step messages, &lt;code&gt;onChunk&lt;/code&gt; and system messages pass the type check and slip past the codemod. Telemetry gets past all three.&lt;/p&gt;

&lt;p&gt;If your app has no tests around its AI calls, write a few before you upgrade. Pin down token usage, what you persist, and what your tools receive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I use the AI SDK migration skill?
&lt;/h2&gt;

&lt;p&gt;Vercel also publishes an agent skill for this upgrade:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx skills add vercel/ai &lt;span class="nt"&gt;--skill&lt;/span&gt; migrate-ai-sdk-v6-to-v7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you ask your coding agent to "use the migrate-ai-sdk-v6-to-v7 skill and migrate my app". The skill is a checklist that points at the migration guide, and it does say the right thing: "When v7 changes semantics, decide whether the app wants the new all-steps behavior or the previous final-step-only behavior." It also covers tool context, &lt;code&gt;finalStep&lt;/code&gt; and &lt;code&gt;@ai-sdk/otel&lt;/code&gt;, which the codemod can't.&lt;/p&gt;

&lt;p&gt;My suggestion: run the codemod first for the mechanical renames, then let an agent with the skill (or yourself, with this list) work through the behaviour changes, and review that diff carefully. If you haven't used skills before, I explained how they work in &lt;a href="https://www.fabwaseem.com/blog/agent-skills" rel="noopener noreferrer"&gt;Agent Skills explained&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install size and performance: is AI SDK 7 heavier?
&lt;/h2&gt;

&lt;p&gt;Barely. A fresh install of &lt;code&gt;ai&lt;/code&gt;, &lt;code&gt;@ai-sdk/openai&lt;/code&gt;, &lt;code&gt;@ai-sdk/react&lt;/code&gt; and React was 23.8 MB across 18 packages on v6 and 24.3 MB across 26 packages on v7. The &lt;code&gt;ai&lt;/code&gt; package itself grew from 7.2 MB to 8.2 MB. It dropped its &lt;code&gt;@opentelemetry/api&lt;/code&gt; dependency (now in &lt;code&gt;@ai-sdk/otel&lt;/code&gt;), while &lt;code&gt;@ai-sdk/react&lt;/code&gt; 4 now pulls in &lt;code&gt;@ai-sdk/mcp&lt;/code&gt; and &lt;code&gt;cross-spawn&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Import time didn't change: &lt;code&gt;import 'ai'&lt;/code&gt; took 113.5 ms on v6 and 115.4 ms on v7 (30 runs each with hyperfine, against 23.8 ms for an empty Node script), which is within noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other AI SDK 7 changes worth checking
&lt;/h2&gt;

&lt;p&gt;My test app didn't use these, but grep your code for them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;prepareStep&lt;/code&gt; overrides carry forward.&lt;/strong&gt; Returned &lt;code&gt;instructions&lt;/code&gt; and &lt;code&gt;messages&lt;/code&gt; now stick for later steps instead of applying to one step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request and response bodies are excluded by default.&lt;/strong&gt; If you log &lt;code&gt;result.request.body&lt;/code&gt;, opt in with &lt;code&gt;include: { requestBody: true }&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP redirects.&lt;/strong&gt; &lt;code&gt;createMCPClient&lt;/code&gt; HTTP and SSE transports now default to &lt;code&gt;redirect: 'error'&lt;/code&gt; to block SSRF. If your MCP server redirects, set &lt;code&gt;redirect: 'follow'&lt;/code&gt;. See my &lt;a href="https://www.fabwaseem.com/blog/mcp-security" rel="noopener noreferrer"&gt;MCP security guide&lt;/a&gt; for why that default matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;needsApproval&lt;/code&gt; is deprecated&lt;/strong&gt; in favour of a &lt;code&gt;toolApproval&lt;/code&gt; setting on the call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image parts&lt;/strong&gt; become &lt;code&gt;{ type: 'file', mediaType: 'image', data }&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;xAI&lt;/strong&gt; now uses the Responses API by default, and &lt;strong&gt;OpenAI&lt;/strong&gt; reasoning summaries default to &lt;code&gt;'detailed'&lt;/code&gt; when reasoning is on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google provider&lt;/strong&gt; names drop &lt;code&gt;GenerativeAI&lt;/code&gt;, for example &lt;code&gt;createGoogleGenerativeAI&lt;/code&gt; → &lt;code&gt;createGoogle&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Common AI SDK 7 migration mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stopping when &lt;code&gt;tsc&lt;/code&gt; passes.&lt;/strong&gt; Most of the deprecated names still compile. My app had 0 type errors with 8 failing tests in between.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not reviewing the meaning of &lt;code&gt;usage&lt;/code&gt;.&lt;/strong&gt; It now covers all steps. Code that adds up per-step usage and then also adds the final &lt;code&gt;usage&lt;/code&gt; will now count every step twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting &lt;code&gt;@ai-sdk/otel&lt;/code&gt;.&lt;/strong&gt; Traces stop without an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting a passing "it answers" test.&lt;/strong&gt; A tool that throws becomes a tool error the model can talk around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turning on &lt;code&gt;allowSystemInMessages&lt;/code&gt; for user data.&lt;/strong&gt; Move system prompts to &lt;code&gt;instructions&lt;/code&gt; instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;AI SDK 7 needs Node.js 22+ and is ESM-only, but &lt;code&gt;require('ai')&lt;/code&gt; still worked on Node 24 thanks to native &lt;code&gt;require(esm)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Bumping &lt;code&gt;ai&lt;/code&gt; 6.0.309 → 7.0.143 gave 6 TypeScript errors and 9 of 13 failing tests on a realistic app.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;npx @ai-sdk/codemod v7&lt;/code&gt; took 20 seconds and fixed 15 lines, but only 1 of the 9 failing tests.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;usage&lt;/code&gt;, &lt;code&gt;toolCalls&lt;/code&gt; and the other results now cover all steps; in my tool loop, usage went from 15 to 30 tokens. Use &lt;code&gt;finalStep&lt;/code&gt; for the old meaning.&lt;/li&gt;
&lt;li&gt;Tool context moved to &lt;code&gt;contextSchema&lt;/code&gt; plus &lt;code&gt;toolsContext&lt;/code&gt;; without it, the tool failed silently while the answer still looked fine.&lt;/li&gt;
&lt;li&gt;OpenTelemetry recorded 0 spans after the upgrade until I added &lt;code&gt;@ai-sdk/otel&lt;/code&gt;, and the span names changed.&lt;/li&gt;
&lt;li&gt;Install size and import time are basically unchanged: 24.3 MB vs 23.8 MB and 115 ms vs 114 ms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Related: my open-source &lt;a href="https://www.fabwaseem.com/blog/nextjs-ai-starter-kit" rel="noopener noreferrer"&gt;Next.js AI starter kit, Forgekit&lt;/a&gt;, already runs on AI SDK 7 (it uses &lt;code&gt;isStepCount&lt;/code&gt;), so you can compare against a working v7 chat app.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/ai-sdk-7-migration" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Add a Remote MCP Server to Cursor, Claude Code, VS Code and Claude Desktop (Tested)</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Fri, 23 Oct 2026 06:26:39 +0000</pubDate>
      <link>https://dev.to/fabwaseem/how-to-add-a-remote-mcp-server-to-cursor-claude-code-vs-code-and-claude-desktop-tested-2g4l</link>
      <guid>https://dev.to/fabwaseem/how-to-add-a-remote-mcp-server-to-cursor-claude-code-vs-code-and-claude-desktop-tested-2g4l</guid>
      <description>&lt;p&gt;A &lt;strong&gt;remote MCP server&lt;/strong&gt; is a Model Context Protocol server you reach over HTTPS instead of starting it on your own machine. You add it with a URL, not a command. Every major AI coding client supports this now, but each one wants the URL in a slightly different shape. Cursor takes a bare &lt;code&gt;url&lt;/code&gt;, Claude Code wants &lt;code&gt;"type": "http"&lt;/code&gt;, VS Code has two file formats, and Claude Desktop doesn't read a URL from its config file at all.&lt;/p&gt;

&lt;p&gt;This guide gives you the working setup for each client, using a real public server: &lt;a href="https://benchline.fabwaseem.com" rel="noopener noreferrer"&gt;Benchline&lt;/a&gt;, my open-source developer tools site, which serves its tools over MCP at &lt;code&gt;https://benchline.fabwaseem.com/mcp&lt;/code&gt; with no API key and no session. I checked every config block against the official docs on October 23, 2026, ran the Claude Code commands for real, called a tool through the official TypeScript SDK and through &lt;code&gt;mcp-remote&lt;/code&gt;, and measured what the server costs in latency and in tokens. All numbers below come from those runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR: add a remote MCP server
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;Where it goes&lt;/th&gt;
&lt;th&gt;What to write&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cursor&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.cursor/mcp.json&lt;/code&gt; or &lt;code&gt;.cursor/mcp.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"mcpServers": { "name": { "url": "…/mcp" } }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;claude mcp add&lt;/code&gt; or &lt;code&gt;.mcp.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;claude mcp add --transport http name …/mcp&lt;/code&gt; (JSON needs &lt;code&gt;"type": "http"&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VS Code&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.vscode/mcp.json&lt;/code&gt;, or &lt;code&gt;.mcp.json&lt;/code&gt; / &lt;code&gt;~/.copilot/mcp-config.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"servers"&lt;/code&gt; with &lt;code&gt;"type": "http"&lt;/code&gt;, or &lt;code&gt;"mcpServers"&lt;/code&gt; with &lt;code&gt;"type": "http"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Desktop&lt;/td&gt;
&lt;td&gt;Customize &amp;gt; Connectors&lt;/td&gt;
&lt;td&gt;Add custom connector and paste the URL. Use &lt;code&gt;mcp-remote&lt;/code&gt; in &lt;code&gt;claude_desktop_config.json&lt;/code&gt; only for servers Anthropic's cloud can't reach&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Before you blame the client, test the server with &lt;code&gt;curl&lt;/code&gt; or the MCP Inspector. A remote server that answers &lt;code&gt;tools/list&lt;/code&gt; will work in all four.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measured from my build box, October 23, 2026&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;initialize&lt;/code&gt;, new connection per request (n=20)&lt;/td&gt;
&lt;td&gt;median 1,487 ms, p95 1,867 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;initialize&lt;/code&gt;, one reused connection (n=20)&lt;/td&gt;
&lt;td&gt;median 373 ms, p95 596 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tools/list&lt;/code&gt;, one reused connection (n=20)&lt;/td&gt;
&lt;td&gt;median 911 ms, p95 973 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tools/list&lt;/code&gt; response size&lt;/td&gt;
&lt;td&gt;138,658 bytes raw, 24,174 bytes gzip&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool definitions the model reads (o200k_base)&lt;/td&gt;
&lt;td&gt;17,517 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tools/call&lt;/code&gt; through the TypeScript SDK, connected (n=20)&lt;/td&gt;
&lt;td&gt;median 375 ms, p95 419 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code 2.1.309, &lt;code&gt;claude mcp add --transport http&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;✔ Connected&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What "remote" means in MCP
&lt;/h2&gt;

&lt;p&gt;MCP has two standard transports. With &lt;strong&gt;stdio&lt;/strong&gt;, the client starts the server as a child process (&lt;code&gt;npx some-server&lt;/code&gt;) and talks to it through stdin and stdout. The server runs on your machine, with your files and your environment variables. With &lt;strong&gt;Streamable HTTP&lt;/strong&gt;, the server is a web endpoint. The client POSTs JSON-RPC messages to one URL and reads the answer as JSON or as a Server-Sent Events stream. The &lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/transports" rel="noopener noreferrer"&gt;transport spec&lt;/a&gt; defines both. The older HTTP+SSE transport is deprecated, and Claude Code's docs say to use HTTP where you can.&lt;/p&gt;

&lt;p&gt;A remote server is just the second kind, hosted somewhere public. That has real advantages. You install nothing, the publisher ships fixes without asking you to update, and the same URL works in every client. It also means the server never sees your disk, which is a security win for tools that don't need it.&lt;/p&gt;

&lt;p&gt;Benchline's endpoint is &lt;strong&gt;stateless&lt;/strong&gt;: each POST stands alone, it returns no &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, and it answers with plain JSON. I explain how that server is built in &lt;a href="https://www.fabwaseem.com/blog/building-benchline-stateless-mcp-server" rel="noopener noreferrer"&gt;Building Benchline: a stateless MCP server&lt;/a&gt;, and what the newer stateless spec changes in &lt;a href="https://www.fabwaseem.com/blog/mcp-server-stateless" rel="noopener noreferrer"&gt;Stateless MCP servers and the 2026-07-28 spec&lt;/a&gt;. For this guide, all you need is the URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the server with curl first
&lt;/h2&gt;

&lt;p&gt;A Streamable HTTP server must get both &lt;code&gt;application/json&lt;/code&gt; and &lt;code&gt;text/event-stream&lt;/code&gt; in the &lt;code&gt;Accept&lt;/code&gt; header. This call lists the tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://benchline.fabwaseem.com/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'accept: application/json, text/event-stream'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And this one calls a tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://benchline.fabwaseem.com/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'accept: application/json, text/event-stream'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"format_json","arguments":{"json":"{\"a\":1}"}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Benchline accepts &lt;code&gt;tools/list&lt;/code&gt; and &lt;code&gt;tools/call&lt;/code&gt; without an &lt;code&gt;initialize&lt;/code&gt; first, because it builds a fresh server for every request. A sessionful server would want &lt;code&gt;initialize&lt;/code&gt; first and an &lt;code&gt;Mcp-Session-Id&lt;/code&gt; header after that. When I sent &lt;code&gt;initialize&lt;/code&gt; with protocol version &lt;code&gt;2025-06-18&lt;/code&gt;, the server answered with &lt;code&gt;serverInfo&lt;/code&gt; &lt;code&gt;benchline&lt;/code&gt;, the &lt;code&gt;tools&lt;/code&gt; capability and no session header.&lt;/p&gt;

&lt;p&gt;If you prefer a real client to raw JSON-RPC, the &lt;a href="https://github.com/modelcontextprotocol/inspector" rel="noopener noreferrer"&gt;MCP Inspector&lt;/a&gt; has a CLI mode. Version 2.12.0 needs Node.js 22.19 or newer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @modelcontextprotocol/inspector &lt;span class="nt"&gt;--cli&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--transport&lt;/span&gt; http &lt;span class="nt"&gt;--server-url&lt;/span&gt; https://benchline.fabwaseem.com/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--method&lt;/span&gt; tools/call &lt;span class="nt"&gt;--tool-name&lt;/span&gt; format_json &lt;span class="nt"&gt;--tool-arg&lt;/span&gt; &lt;span class="s1"&gt;'json="{\"a\":1}"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha I hit: &lt;code&gt;--tool-arg&lt;/code&gt; parses the value as JSON. My first try, &lt;code&gt;json={"a":1}&lt;/code&gt;, sent an object, and the tool rejected it with &lt;code&gt;-32602 Input validation error: expected string, received object&lt;/code&gt;. Wrapping the value in quotes makes it a JSON string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cursor
&lt;/h2&gt;

&lt;p&gt;Cursor reads &lt;code&gt;~/.cursor/mcp.json&lt;/code&gt; for servers you want in every project, and &lt;code&gt;.cursor/mcp.json&lt;/code&gt; in a project for that project only. A remote server needs only a &lt;code&gt;url&lt;/code&gt;:&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;"mcpServers"&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;"benchline"&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;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://benchline.fabwaseem.com/mcp"&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;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;For a server that needs a token, add &lt;code&gt;headers&lt;/code&gt; and read the value from your environment with Cursor's &lt;code&gt;${env:NAME}&lt;/code&gt; interpolation, so the secret never lands in the file:&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;"mcpServers"&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;"my-api"&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;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.example.com/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&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;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer ${env:MY_SERVICE_TOKEN}"&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;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;Servers that use OAuth need no header. Cursor runs the sign-in itself, using PKCE and the server's protected resource metadata. If a server doesn't show up, open the Output panel and pick &lt;strong&gt;MCP Logs&lt;/strong&gt;. Source: &lt;a href="https://cursor.com/docs/context/mcp" rel="noopener noreferrer"&gt;Cursor MCP docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Code
&lt;/h2&gt;

&lt;p&gt;Claude Code has a one-line command for remote servers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http benchline https://benchline.fabwaseem.com/mcp
claude mcp list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Claude Code 2.1.309 this printed &lt;code&gt;Added HTTP MCP server benchline with URL: https://benchline.fabwaseem.com/mcp to local config&lt;/code&gt;, and &lt;code&gt;claude mcp list&lt;/code&gt; showed &lt;code&gt;benchline: https://benchline.fabwaseem.com/mcp (HTTP) - ✔ Connected&lt;/code&gt;. The default scope is &lt;strong&gt;local&lt;/strong&gt;: the entry is saved in &lt;code&gt;~/.claude.json&lt;/code&gt; under the current project. Add &lt;code&gt;--scope user&lt;/code&gt; to use it in every project, or &lt;code&gt;--scope project&lt;/code&gt; to write it to &lt;code&gt;.mcp.json&lt;/code&gt; and commit it for your team. For a token, pass &lt;code&gt;--header "Authorization: Bearer …"&lt;/code&gt;. For OAuth servers, run &lt;code&gt;/mcp&lt;/code&gt; inside a session or &lt;code&gt;claude mcp login &amp;lt;name&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The project file looks like this:&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;"mcpServers"&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;"benchline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://benchline.fabwaseem.com/mcp"&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;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Don't paste a Cursor block into &lt;code&gt;.mcp.json&lt;/code&gt;.&lt;/strong&gt; Claude Code reads an entry without &lt;code&gt;type&lt;/code&gt; as a stdio server. When I tried the Cursor-style block above, &lt;code&gt;claude mcp list&lt;/code&gt; printed &lt;code&gt;Skipped — invalid MCP server config for "benchline": command: expected string, received undefined&lt;/code&gt;, and the server was not loaded. Adding &lt;code&gt;"type": "http"&lt;/code&gt; fixes it, and &lt;code&gt;"streamable-http"&lt;/code&gt; works as an alias. Source: &lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;Claude Code MCP docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  VS Code
&lt;/h2&gt;

&lt;p&gt;VS Code now reads two formats, and it matters which file you edit.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;VS Code format&lt;/strong&gt; lives in &lt;code&gt;.vscode/mcp.json&lt;/code&gt; in your workspace, or in your user profile. It uses a top-level &lt;code&gt;servers&lt;/code&gt; object, and &lt;code&gt;type&lt;/code&gt; is required:&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;"servers"&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;"benchline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://benchline.fabwaseem.com/mcp"&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;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 &lt;strong&gt;portable format&lt;/strong&gt; lives in &lt;code&gt;.mcp.json&lt;/code&gt; at the workspace root, or in &lt;code&gt;~/.copilot/mcp-config.json&lt;/code&gt; for your user. It uses &lt;code&gt;mcpServers&lt;/code&gt;, the same shape as Claude Code's file, so one committed &lt;code&gt;.mcp.json&lt;/code&gt; can serve both tools:&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;"mcpServers"&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;"benchline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://benchline.fabwaseem.com/mcp"&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;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 guided &lt;strong&gt;MCP: Add Server&lt;/strong&gt; command now suggests the Copilot Global file first and labels &lt;code&gt;.vscode/mcp.json&lt;/code&gt; as deprecated, though VS Code still reads it. The two formats handle secrets differently. &lt;code&gt;.vscode/mcp.json&lt;/code&gt; supports &lt;code&gt;${input:…}&lt;/code&gt; prompts and &lt;code&gt;${env:NAME}&lt;/code&gt;, while portable files use &lt;code&gt;${NAME}&lt;/code&gt; and no input prompts. VS Code first tries Streamable HTTP and falls back to SSE. Source: &lt;a href="https://code.visualstudio.com/docs/copilot/reference/mcp-configuration" rel="noopener noreferrer"&gt;VS Code MCP configuration reference&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Desktop
&lt;/h2&gt;

&lt;p&gt;This is where most copy-paste setups break. &lt;strong&gt;&lt;code&gt;claude_desktop_config.json&lt;/code&gt; is for local stdio servers.&lt;/strong&gt; The supported way to add a remote server is a custom connector:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;strong&gt;Customize &amp;gt; Connectors&lt;/strong&gt; (on the web, &lt;code&gt;claude.ai/customize/connectors&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;+ Add&lt;/strong&gt;, then &lt;strong&gt;Add custom connector&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Enter a name and the server URL, &lt;code&gt;https://benchline.fabwaseem.com/mcp&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Under Authentication, pick &lt;strong&gt;No sign in&lt;/strong&gt; for a public server like Benchline, or sign in for an OAuth server. There is also a Request headers section for API keys.&lt;/li&gt;
&lt;li&gt;In a chat, enable it from &lt;strong&gt;+ &amp;gt; Connectors&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Custom connectors work on Free (one connector), Pro, Max, Team and Enterprise plans. On Team and Enterprise, an Owner adds the connector first. One detail matters a lot: &lt;strong&gt;Claude connects to a custom connector from Anthropic's cloud, not from your computer.&lt;/strong&gt; The server has to be reachable from the public internet. A server on &lt;code&gt;localhost&lt;/code&gt;, behind a VPN or on a company network won't connect, even if &lt;code&gt;curl&lt;/code&gt; works on your laptop. Source: &lt;a href="https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp" rel="noopener noreferrer"&gt;Claude Help Center&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For those servers, bridge the URL to stdio with &lt;a href="https://github.com/punkpeye/mcp-remote" rel="noopener noreferrer"&gt;&lt;code&gt;mcp-remote&lt;/code&gt;&lt;/a&gt; in &lt;code&gt;claude_desktop_config.json&lt;/code&gt; (on macOS in &lt;code&gt;~/Library/Application Support/Claude/&lt;/code&gt;, on Windows in &lt;code&gt;%APPDATA%\Claude\&lt;/code&gt;):&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;"mcpServers"&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;"benchline"&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;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&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="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp-remote"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://benchline.fabwaseem.com/mcp"&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="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;Claude Desktop starts &lt;code&gt;mcp-remote&lt;/code&gt; as a local process, and &lt;code&gt;mcp-remote&lt;/code&gt; speaks Streamable HTTP to the server from your own network. I tested this by starting &lt;code&gt;mcp-remote&lt;/code&gt; 0.15.2 from the official TypeScript SDK's stdio client, the way Claude Desktop would. The &lt;code&gt;format_json&lt;/code&gt; call came back valid. The first start took 11.2 s, including &lt;code&gt;npx&lt;/code&gt; fetching the package, and the call itself took 970 ms. For a token, use &lt;code&gt;--header&lt;/code&gt; and keep the value in &lt;code&gt;env&lt;/code&gt;, or &lt;code&gt;--header-file&lt;/code&gt; so the secret stays out of the process list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Call a tool with the official TypeScript SDK
&lt;/h2&gt;

&lt;p&gt;To check a server from code, or to use one in your own agent, the official SDK client takes about ten lines:&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;Client&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;@modelcontextprotocol/sdk/client/index.js&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;StreamableHTTPClientTransport&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;@modelcontextprotocol/sdk/client/streamableHttp.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&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;Client&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;remote-mcp-check&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&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;StreamableHTTPClientTransport&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;https://benchline.fabwaseem.com/mcp&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tools&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listTools&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;callTool&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;format_json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"b":2,"a":[1,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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;structuredContent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// { output: '{\n  "b": 2, …', valid: true, errors: [] }&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; 1.32.1, the client negotiated protocol &lt;code&gt;2025-11-25&lt;/code&gt;, got no session id, and the call returned &lt;code&gt;structuredContent&lt;/code&gt; with &lt;code&gt;valid: true&lt;/code&gt;. Twenty calls on that connected client took a median of 375 ms and a p95 of 419 ms.&lt;/p&gt;

&lt;p&gt;The v2 client, &lt;code&gt;@modelcontextprotocol/client&lt;/code&gt; 2.3.1, with &lt;code&gt;versionNegotiation: { mode: "auto" }&lt;/code&gt; also connected and fell back to &lt;code&gt;2025-11-25&lt;/code&gt;, because Benchline's server doesn't speak &lt;code&gt;2026-07-28&lt;/code&gt; yet. A direct &lt;code&gt;2026-07-28&lt;/code&gt; request got &lt;code&gt;Unsupported protocol version&lt;/code&gt;, and the list of versions the server does accept. Clients that negotiate handle this for you. Claude Code's v2 runtime, for example, asks each server which revision it supports and uses the newer one only where the server has it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a remote MCP server costs your context window
&lt;/h2&gt;

&lt;p&gt;Speed is rarely the problem with MCP. Tokens are. Each tool's name, description and JSON Schema can end up in the model's context, and that's paid on every turn. So I measured Benchline's &lt;code&gt;tools/list&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The full JSON-RPC response is &lt;strong&gt;138,658 bytes&lt;/strong&gt;. With &lt;code&gt;curl --compressed&lt;/code&gt; the server sent 24,174 bytes of gzip.&lt;/li&gt;
&lt;li&gt;Counted with the &lt;code&gt;o200k_base&lt;/code&gt; tokenizer (via &lt;code&gt;js-tiktoken&lt;/code&gt;), the whole response is &lt;strong&gt;34,307 tokens&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Keep only what a model needs to choose and call a tool, meaning &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt; and &lt;code&gt;inputSchema&lt;/code&gt;, and it comes to &lt;strong&gt;17,517 tokens&lt;/strong&gt; (72,398 bytes of JSON). A rough characters-divided-by-4 estimate gives 18,100. Benchline's output schemas make up 52,458 bytes of the full response.&lt;/li&gt;
&lt;li&gt;The largest single tool definition, &lt;code&gt;build_open_graph&lt;/code&gt;, is 584 tokens. The smallest is 75.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are approximations. &lt;code&gt;o200k_base&lt;/code&gt; is OpenAI's tokenizer, Claude and other models count differently, and each client decides which fields it forwards. Still, the order of magnitude holds. For comparison, the MCP server behind this site came to 5,949 tokens in my &lt;a href="https://www.fabwaseem.com/blog/mcp-vs-api" rel="noopener noreferrer"&gt;MCP vs API benchmark&lt;/a&gt;. Some clients soften the cost. Claude Code uses tool search by default, so Claude can look up tools when it needs them. VS Code lets you choose which tools a session can use, and Cursor lets you toggle whole servers off. Turn off what you aren't using.&lt;/p&gt;

&lt;h2&gt;
  
  
  Latency: why the first request is slow
&lt;/h2&gt;

&lt;p&gt;I measured with &lt;code&gt;curl&lt;/code&gt; from my build box, which reaches the internet through an egress proxy, so the absolute numbers include that path. Yours will differ. The pattern is the useful part:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request (n=20 each)&lt;/th&gt;
&lt;th&gt;New connection per request&lt;/th&gt;
&lt;th&gt;One reused connection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;initialize&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;median 1,487 ms, p95 1,867 ms&lt;/td&gt;
&lt;td&gt;median 373 ms, p95 596 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tools/list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;median 2,043 ms, p95 2,391 ms&lt;/td&gt;
&lt;td&gt;median 911 ms, p95 973 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On a fresh connection, the TCP and TLS setup alone took a median of 928 ms before the first byte of the request went out. On a reused connection, &lt;code&gt;tools/list&lt;/code&gt; sent its first byte after a median of 395 ms, and most of the remaining time went into downloading the uncompressed 138 KB body. Real clients keep connections open and fetch the tool list once per session, so you pay the slow path roughly once. Two practical points follow. A server with a large tool list should support compression. And &lt;code&gt;MCP_TIMEOUT&lt;/code&gt; in Claude Code, or your client's equivalent, should allow a few seconds for the first connect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting remote MCP servers
&lt;/h2&gt;

&lt;p&gt;Every error below is one I triggered against the live server or found in the client docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;406 Not Acceptable: Client must accept both application/json and text/event-stream&lt;/code&gt;.&lt;/strong&gt; Your &lt;code&gt;Accept&lt;/code&gt; header is missing one of the two types. Real clients send both; hand-written &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;fetch&lt;/code&gt; calls often don't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;405 Method not allowed&lt;/code&gt; on GET.&lt;/strong&gt; Normal for a stateless server that offers no server-to-client stream. The spec allows it, and clients carry on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Skipped — invalid MCP server config … command: expected string&lt;/code&gt;&lt;/strong&gt; in Claude Code. You have a &lt;code&gt;url&lt;/code&gt; without &lt;code&gt;"type": "http"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A connector works in &lt;code&gt;curl&lt;/code&gt; but not in Claude Desktop.&lt;/strong&gt; Anthropic's cloud makes the connection, so it can't reach private networks. Use &lt;code&gt;mcp-remote&lt;/code&gt;, or put the server somewhere public.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;401 Unauthorized&lt;/code&gt;.&lt;/strong&gt; The server wants OAuth or a token. Claude Code flags the server under &lt;code&gt;/mcp&lt;/code&gt;, where you can sign in, and Cursor and VS Code both support OAuth for HTTP servers. If you configured a static &lt;code&gt;Authorization&lt;/code&gt; header and it's rejected, fix the token: Claude Code won't fall back to OAuth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;429 Too many requests&lt;/code&gt;.&lt;/strong&gt; Public servers rate-limit. Benchline allows 60 requests a minute per address and sends &lt;code&gt;retry-after&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Unsupported protocol version&lt;/code&gt;.&lt;/strong&gt; The client asked for a revision the server doesn't have. Use a client that negotiates, or pin a version both support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spaces in &lt;code&gt;mcp-remote&lt;/code&gt; header args on Windows.&lt;/strong&gt; The mcp-remote README warns that Cursor, Codex CLI and Claude Desktop on Windows don't escape spaces inside &lt;code&gt;args&lt;/code&gt;. Write &lt;code&gt;Authorization:${AUTH_HEADER}&lt;/code&gt; with no space and put &lt;code&gt;Bearer …&lt;/code&gt; in &lt;code&gt;env&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Security checklist for remote MCP servers
&lt;/h2&gt;

&lt;p&gt;A remote server can't read your files, but it can still influence your agent. Before adding one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trust the publisher.&lt;/strong&gt; Tool descriptions and tool output reach your model, and both can carry prompt injection. Servers that fetch web content are the main risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep secrets out of config files.&lt;/strong&gt; Use &lt;code&gt;${env:NAME}&lt;/code&gt; in Cursor, &lt;code&gt;${NAME}&lt;/code&gt; in Claude Code and portable VS Code files, or &lt;code&gt;${input:…}&lt;/code&gt; in &lt;code&gt;.vscode/mcp.json&lt;/code&gt;. Never commit a populated &lt;code&gt;Authorization&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer OAuth over shared keys&lt;/strong&gt; for anything multi-user, and read what scopes you grant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep tool approval on&lt;/strong&gt; for tools that write or delete, and approve one call at a time until you trust a server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin &lt;code&gt;mcp-remote&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;mcp-remote@0.15.2&lt;/code&gt;) in shared configs, so &lt;code&gt;npx&lt;/code&gt; doesn't pull a new version silently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the server side, including the OAuth SDK flaws I reproduced this month, read &lt;a href="https://www.fabwaseem.com/blog/mcp-security" rel="noopener noreferrer"&gt;MCP security in 2026&lt;/a&gt;. If you want to build a remote server of your own, &lt;a href="https://www.fabwaseem.com/blog/build-mcp-server-nextjs" rel="noopener noreferrer"&gt;How to build an MCP server in Next.js&lt;/a&gt; covers the route handler, bearer auth and Origin checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested
&lt;/h2&gt;

&lt;p&gt;All tests ran on October 23, 2026, from my Linux build box against the live &lt;code&gt;https://benchline.fabwaseem.com/mcp&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Latency:&lt;/strong&gt; &lt;code&gt;curl&lt;/code&gt; 8.14.1, 20 sequential &lt;code&gt;initialize&lt;/code&gt; and 20 &lt;code&gt;tools/list&lt;/code&gt; POSTs with a new connection each and a 1.5 s pause to stay under the rate limit. Then 20 of each on one connection with &lt;code&gt;curl --next&lt;/code&gt;. Median and p95 come from &lt;code&gt;time_total&lt;/code&gt;, using linear interpolation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size and tokens:&lt;/strong&gt; the saved &lt;code&gt;tools/list&lt;/code&gt; body, counted with &lt;code&gt;js-tiktoken&lt;/code&gt; and the &lt;code&gt;o200k_base&lt;/code&gt; ranks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SDK:&lt;/strong&gt; &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; 1.32.1 and &lt;code&gt;@modelcontextprotocol/client&lt;/code&gt; 2.3.1 on Node.js 20.19. Twenty &lt;code&gt;format_json&lt;/code&gt; calls on one connected client, 1.2 s apart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clients:&lt;/strong&gt; Claude Code 2.1.309 in a throwaway &lt;code&gt;HOME&lt;/code&gt;. &lt;code&gt;mcp-remote&lt;/code&gt; 0.15.2 started over stdio by the SDK. MCP Inspector 2.12.0 CLI. I checked the Cursor, VS Code and Claude Desktop syntax against their official docs on the same day, but didn't run those apps on the box.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is a remote MCP server?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A Model Context Protocol server you reach over HTTPS with the Streamable HTTP transport, instead of a local process your client starts over stdio. You add it with a URL, nothing is installed on your machine, and the same URL works in Cursor, Claude Code, VS Code and Claude Desktop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I add a remote MCP server to Cursor?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Add an entry with only a url under mcpServers in ~/.cursor/mcp.json for all projects, or .cursor/mcp.json for one project, for example "benchline": { "url": "&lt;a href="https://benchline.fabwaseem.com/mcp" rel="noopener noreferrer"&gt;https://benchline.fabwaseem.com/mcp&lt;/a&gt;" }. Add headers with ${env:NAME} for token auth. Cursor runs OAuth itself for servers that need it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is the claude mcp add command for an HTTP server?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;claude mcp add --transport http  , for example claude mcp add --transport http benchline &lt;a href="https://benchline.fabwaseem.com/mcp" rel="noopener noreferrer"&gt;https://benchline.fabwaseem.com/mcp&lt;/a&gt;. It saves to local scope by default. Add --scope user for every project or --scope project to write .mcp.json, and --header for a bearer token. Check it with claude mcp list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does Claude Code skip my MCP server from .mcp.json?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An entry with a url but no type is read as a stdio server and skipped. In my test Claude Code 2.1.309 reported command: expected string, received undefined. Add "type": "http" (or the alias "streamable-http") next to the url.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I put a remote URL in claude_desktop_config.json?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. That file is for local stdio servers. Add remote servers in Claude Desktop under Customize &amp;gt; Connectors &amp;gt; Add custom connector. For a server that Anthropic's cloud cannot reach, run npx -y mcp-remote  as a stdio command in claude_desktop_config.json.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my custom connector fail in Claude when curl works?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Claude connects to custom connectors from Anthropic's cloud, not from your computer. A server on localhost, a VPN or a private network is unreachable from there. Host it publicly, allowlist Anthropic's IP ranges, or bridge it with mcp-remote, which connects from your own network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many tokens does an MCP server add to the context?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It depends on its tool schemas. Benchline's tool names, descriptions and input schemas came to 17,517 tokens with the o200k_base tokenizer, and the whole tools/list response to 34,307. The MCP server behind fabwaseem.com is 5,949 tokens. Turn off servers and tools you don't need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I test a remote MCP server without an AI client?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;POST a tools/list JSON-RPC request with curl, sending content-type: application/json and accept: application/json, text/event-stream, or run npx @modelcontextprotocol/inspector --cli --transport http --server-url  --method tools/list. A missing Accept type returns HTTP 406.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A remote MCP server is a Streamable HTTP endpoint. You add it with a URL, and nothing runs on your machine.&lt;/li&gt;
&lt;li&gt;Cursor takes a bare &lt;code&gt;url&lt;/code&gt;. Claude Code and VS Code need &lt;code&gt;"type": "http"&lt;/code&gt;, and Claude Code skips a &lt;code&gt;url&lt;/code&gt; entry without it.&lt;/li&gt;
&lt;li&gt;Claude Desktop adds remote servers as custom connectors, and the connection comes from Anthropic's cloud. Use &lt;code&gt;mcp-remote&lt;/code&gt; in &lt;code&gt;claude_desktop_config.json&lt;/code&gt; for servers that cloud can't reach.&lt;/li&gt;
&lt;li&gt;Test with &lt;code&gt;curl&lt;/code&gt; or the MCP Inspector first. The &lt;code&gt;Accept&lt;/code&gt; header must list both &lt;code&gt;application/json&lt;/code&gt; and &lt;code&gt;text/event-stream&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Benchline's tool definitions came to 17,517 tokens (o200k_base). Context cost is the real price of adding a server, so turn off tools you don't use.&lt;/li&gt;
&lt;li&gt;On one connection, a &lt;code&gt;tools/call&lt;/code&gt; through the TypeScript SDK took a median of 375 ms. The first connect is the slow part.&lt;/li&gt;
&lt;li&gt;Keep tokens in environment variables, prefer OAuth, and leave tool approval on for anything that writes.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/add-remote-mcp-server" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;, where I write about MCP, AI automation, Node.js and Next.js.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>cursor</category>
      <category>vscode</category>
    </item>
    <item>
      <title>Server-Sent Events in NestJS: Live Job Progress with @Sse, and the Gotchas I Hit in Chrome</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Mon, 19 Oct 2026 05:29:34 +0000</pubDate>
      <link>https://dev.to/fabwaseem/server-sent-events-in-nestjs-live-job-progress-with-sse-and-the-gotchas-i-hit-in-chrome-1lc4</link>
      <guid>https://dev.to/fabwaseem/server-sent-events-in-nestjs-live-job-progress-with-sse-and-the-gotchas-i-hit-in-chrome-1lc4</guid>
      <description>&lt;p&gt;I added live progress to a long-running job API with NestJS's &lt;code&gt;@Sse()&lt;/code&gt; decorator and it worked on the first try. Then I tested it properly, in a real browser, and found three things the ten-line example doesn't warn you about: a finished stream makes &lt;code&gt;EventSource&lt;/code&gt; reconnect and replay it, Nest's automatic event ids restart at 1 on every connection, and six open streams over HTTP/1.1 freeze every other request to that host.&lt;/p&gt;

&lt;p&gt;This is the write-up. The patterns come from &lt;a href="https://github.com/fabwaseem/snagbox" rel="noopener noreferrer"&gt;Snagbox&lt;/a&gt;, my open-source NestJS media downloader API, which streams download progress over SSE. Every behaviour below was checked on NestJS 12.1.3, Node.js 22.20 and headless Chrome 155, and every number was measured on my machine (method at the end).&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR: NestJS SSE in production
&lt;/h2&gt;

&lt;p&gt;Return an &lt;code&gt;Observable&amp;lt;MessageEvent&amp;gt;&lt;/code&gt; from an &lt;code&gt;@Sse()&lt;/code&gt; route. Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Give each job its own &lt;code&gt;ReplaySubject(1)&lt;/code&gt;, so a client that connects mid-job gets the latest progress at once. In my test that took 2 to 4 ms, against 451 to 1,002 ms with a plain &lt;code&gt;Subject&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;id&lt;/code&gt; on every message yourself. Nest numbers messages per connection, so after a reconnect the ids start at 1 again and &lt;code&gt;Last-Event-ID&lt;/code&gt; is useless.&lt;/li&gt;
&lt;li&gt;Close the &lt;code&gt;EventSource&lt;/code&gt; on the client when the job finishes. When your Observable completes, the browser treats it as a dropped connection and reconnects, and it replayed my stream three times in 1.6 seconds.&lt;/li&gt;
&lt;li&gt;Send a heartbeat as a comment frame (&lt;code&gt;{ comment: "ping" }&lt;/code&gt;). The client never sees it, and Nest gives it no id.&lt;/li&gt;
&lt;li&gt;Don't open one stream per item in a batch. Over HTTP/1.1, Chrome opened 6 of 8 streams to my server, and an ordinary &lt;code&gt;fetch()&lt;/code&gt; to the same host was still waiting 5 seconds later. Poll one batch endpoint instead, or serve over HTTP/2.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Result (measured on the box)&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;One 10 s job, 40 updates, SSE&lt;/td&gt;
&lt;td&gt;1 request, 40 of 40 updates, 4,516 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same job, polling every 1 s&lt;/td&gt;
&lt;td&gt;11 requests, 10 of 40 updates, 4,438 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same job, polling every 250 ms&lt;/td&gt;
&lt;td&gt;41 requests, 40 of 40 updates, 16,506 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSE delivery lag on localhost&lt;/td&gt;
&lt;td&gt;0.8 ms mean, 2 ms max&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Late joiner, &lt;code&gt;ReplaySubject(1)&lt;/code&gt; vs &lt;code&gt;Subject&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2 to 4 ms vs 451 to 1,002 ms to first frame&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server memory per open stream&lt;/td&gt;
&lt;td&gt;about 34 to 64 KB of RSS (5,000 and 1,000 streams)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chrome 155, HTTP/1.1, 8 &lt;code&gt;EventSource&lt;/code&gt;s&lt;/td&gt;
&lt;td&gt;6 opened, the 7th, 8th and a &lt;code&gt;fetch()&lt;/code&gt; stalled&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What are Server-Sent Events?
&lt;/h2&gt;

&lt;p&gt;SSE is a plain HTTP response with &lt;code&gt;Content-Type: text/event-stream&lt;/code&gt; that never ends until one side closes it. The server writes UTF-8 frames separated by a blank line. Each frame can carry &lt;code&gt;data:&lt;/code&gt;, an &lt;code&gt;event:&lt;/code&gt; name, an &lt;code&gt;id:&lt;/code&gt; and a &lt;code&gt;retry:&lt;/code&gt; delay, and a line starting with &lt;code&gt;:&lt;/code&gt; is a comment. The format and the reconnection rules are in the &lt;a href="https://html.spec.whatwg.org/multipage/server-sent-events.html" rel="noopener noreferrer"&gt;WHATWG HTML spec&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;In the browser, &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource" rel="noopener noreferrer"&gt;&lt;code&gt;EventSource&lt;/code&gt;&lt;/a&gt; does all the client work. It parses frames, fires events, remembers the last id, and reconnects after a dropped connection, sending that id back in a &lt;code&gt;Last-Event-ID&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;SSE only goes from the server to the client. For job progress, notifications, build logs and streamed LLM tokens, that is all you need. The client can still send commands through normal POST requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSE vs WebSockets vs polling
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;SSE&lt;/th&gt;
&lt;th&gt;WebSockets&lt;/th&gt;
&lt;th&gt;Polling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direction&lt;/td&gt;
&lt;td&gt;Server to client&lt;/td&gt;
&lt;td&gt;Both ways&lt;/td&gt;
&lt;td&gt;Client asks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protocol&lt;/td&gt;
&lt;td&gt;Plain HTTP&lt;/td&gt;
&lt;td&gt;Upgrade to &lt;code&gt;ws://&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Plain HTTP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reconnects&lt;/td&gt;
&lt;td&gt;Built into &lt;code&gt;EventSource&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;You write it&lt;/td&gt;
&lt;td&gt;Not needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works through HTTP proxies and CDNs&lt;/td&gt;
&lt;td&gt;Usually, if buffering is off&lt;/td&gt;
&lt;td&gt;Needs upgrade support&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom request headers from the browser&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nest setup&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@Sse()&lt;/code&gt; in a controller&lt;/td&gt;
&lt;td&gt;A gateway and an adapter&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;@Get()&lt;/code&gt; route&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use WebSockets when the client also streams data back at a high rate, for example in chat or collaborative editing. Use polling when updates are rare or when you track many items at once (more on that below). For one long job and one watcher, SSE is the best fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use @Sse in NestJS
&lt;/h2&gt;

&lt;p&gt;The smallest working endpoint returns an Observable. Nest writes every emitted value as a frame and ends the response when the Observable completes.&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;Controller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MessageEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Sse&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;@nestjs/common&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;Observable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;take&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;rxjs&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="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;demo&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DemoController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Sse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ticks&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;ticks&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Observable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;MessageEvent&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;return&lt;/span&gt; &lt;span class="nf"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&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;i&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="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tick&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Objects in &lt;code&gt;data&lt;/code&gt; are serialized as JSON. The &lt;code&gt;MessageEvent&lt;/code&gt; interface in &lt;code&gt;@nestjs/common&lt;/code&gt; 12.1.3 has five optional fields: &lt;code&gt;data&lt;/code&gt;, &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;retry&lt;/code&gt; and &lt;code&gt;comment&lt;/code&gt;. The full list of features is in the &lt;a href="https://docs.nestjs.com/techniques/server-sent-events" rel="noopener noreferrer"&gt;NestJS SSE docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I checked the response headers with &lt;code&gt;curl -D -&lt;/code&gt;. Nest 12.1.3 sends &lt;code&gt;Content-Type: text/event-stream&lt;/code&gt;, &lt;code&gt;Cache-Control: private, no-cache, no-store, must-revalidate, max-age=0, no-transform&lt;/code&gt;, &lt;code&gt;Connection: keep-alive&lt;/code&gt; and &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt;. The last one tells Nginx not to buffer the stream, which is the most common reason SSE "works locally but not in production". You don't have to set it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Streaming job progress: one ReplaySubject per job
&lt;/h2&gt;

&lt;p&gt;A real job runs in a worker, not in the controller. The worker needs somewhere to publish progress, and the SSE route needs somewhere to subscribe. In Snagbox that is a small service holding one RxJS subject per job (&lt;a href="https://github.com/fabwaseem/snagbox/blob/main/src/downloads/job-events.service.ts" rel="noopener noreferrer"&gt;source&lt;/a&gt;). This is a trimmed 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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Injectable&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;@nestjs/common&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;Observable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ReplaySubject&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;rxjs&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;JobEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;progress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;percentage&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="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;complete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;url&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;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;message&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="nd"&gt;Injectable&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;class&lt;/span&gt; &lt;span class="nc"&gt;JobEventsService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&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="nx"&gt;ReplaySubject&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;JobEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&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;ReplaySubject&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;JobEvent&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;let&lt;/span&gt; &lt;span class="nx"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;channels&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="nx"&gt;jobId&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;subject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Buffer of 1: a client that joins mid-job gets the latest frame at once.&lt;/span&gt;
      &lt;span class="nx"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;ReplaySubject&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;JobEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;channels&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="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subject&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;subject&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&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;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JobEvent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&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;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JobEvent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&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;subject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;subject&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="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="c1"&gt;// Late subscribers still get the terminal frame for a while, then we free it.&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;unref&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&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;Observable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;JobEvent&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;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;asObservable&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;Why &lt;code&gt;ReplaySubject(1)&lt;/code&gt; and not &lt;code&gt;Subject&lt;/code&gt;? Users don't open the progress page at the exact moment a job starts. They refresh, switch tabs, or open it from a notification. A plain &lt;code&gt;Subject&lt;/code&gt; only delivers future values, so a late client sees an empty progress bar until the next update arrives.&lt;/p&gt;

&lt;p&gt;I measured this. A job emitted progress every 2 seconds, and a client connected between two updates, 5 seconds in, five times. With &lt;code&gt;ReplaySubject(1)&lt;/code&gt; the first frame arrived in 2 to 4 ms. With &lt;code&gt;Subject&lt;/code&gt; it took 451 to 1,002 ms, which is simply the wait for the next emit. With slower jobs, such as a big download that reports every few seconds, that wait gets longer.&lt;/p&gt;

&lt;p&gt;Keep the buffer at 1. Progress is a state, not a log, so the latest frame is all a new client needs, and a bigger buffer only replays stale percentages.&lt;/p&gt;

&lt;p&gt;This map lives in one process. If you run several API instances, the worker's events have to reach whichever instance holds the client's connection. Put a broker in between, such as Redis pub/sub, and feed each instance's subjects from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The production SSE controller
&lt;/h2&gt;

&lt;p&gt;Here is the route, close to Snagbox's &lt;a href="https://github.com/fabwaseem/snagbox/blob/main/src/downloads/downloads.controller.ts" rel="noopener noreferrer"&gt;downloads controller&lt;/a&gt; but with three changes I made after testing:&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;Controller&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MessageEvent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NotFoundException&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Sse&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;@nestjs/common&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;Observable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endWith&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ignoreElements&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;share&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;takeUntil&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;rxjs&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;JobEventsService&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;JobEvent&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;./job-events.service.js&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;JobStore&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;./job-store.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HEARTBEAT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&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;toMessage&lt;/span&gt; &lt;span class="o"&gt;=&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="nx"&gt;JobEvent&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;MessageEvent&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nc"&gt;String&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="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;data&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="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Controller&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jobs&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;JobsController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JobEventsService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JobStore&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="nd"&gt;Sse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;:id/events&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&gt;Param&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;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;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;Observable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;MessageEvent&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;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;store&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="nx"&gt;id&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;job&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;NotFoundException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`No job &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;// Finished before the client connected: send the terminal frame and stop.&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;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;last&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="s2"&gt;progress&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="k"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;toMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;last&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;events$&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&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;pipe&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;toMessage&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;share&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;done$&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;events$&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ignoreElements&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;endWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="c1"&gt;// A comment frame (": ping") keeps proxies from reaping the idle socket.&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;heartbeat$&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HEARTBEAT_MS&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pipe&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;MessageEvent&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ping&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
      &lt;span class="nf"&gt;takeUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;done$&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;events$&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;heartbeat$&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;JobStore&lt;/code&gt; is wherever you keep job state. Snagbox keeps its download records in Postgres through Prisma. I ran this exact controller and checked each branch with curl:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An unknown id returns a normal &lt;code&gt;404&lt;/code&gt; JSON response. Throwing before you return the Observable works, because Nest hasn't sent the stream headers yet. Under the spec, a non-200 response makes &lt;code&gt;EventSource&lt;/code&gt; give up rather than retry, which is what you want for a job that doesn't exist.&lt;/li&gt;
&lt;li&gt;A finished job returns one &lt;code&gt;complete&lt;/code&gt; frame and ends.&lt;/li&gt;
&lt;li&gt;A running job streams frames with &lt;code&gt;id: 6&lt;/code&gt;, &lt;code&gt;id: 7&lt;/code&gt; and so on, matching the job's own sequence number, with &lt;code&gt;: ping&lt;/code&gt; comments in between.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why set the id yourself
&lt;/h3&gt;

&lt;p&gt;If a message has no &lt;code&gt;id&lt;/code&gt;, Nest 12.1.3 adds one: a counter that starts at 1 on each connection. I tested what that means for reconnects in headless Chrome. The browser reconnected and sent &lt;code&gt;Last-Event-ID: 3&lt;/code&gt;, and the new connection numbered its frames from 1 again. The header is there, but it no longer means anything.&lt;/p&gt;

&lt;p&gt;Using the job's sequence number as the id fixes that. On reconnect, read the header with &lt;code&gt;@Headers("last-event-id")&lt;/code&gt; and skip events the client has already seen. For progress, the &lt;code&gt;ReplaySubject&lt;/code&gt; already sends the latest state, so you can often ignore the header and still be correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  The heartbeat: a comment, not a data frame
&lt;/h3&gt;

&lt;p&gt;Snagbox merges a heartbeat into the stream every 15 seconds. The comment in its source says why: proxies reap idle connections, and the Next.js rewrite proxy in front of the API is one of them. &lt;code&gt;takeUntil(done$)&lt;/code&gt; stops the timer when the job ends, so the heartbeat never holds a finished stream open.&lt;/p&gt;

&lt;p&gt;Snagbox sends the heartbeat as &lt;code&gt;data: {"type":"ping"}&lt;/code&gt; and every client has to ignore it. On Nest 12.1.3 you can return &lt;code&gt;{ comment: "ping" }&lt;/code&gt; instead. Nest writes it as &lt;code&gt;: ping&lt;/code&gt;, gives it no id, and &lt;code&gt;EventSource&lt;/code&gt; doesn't fire any event for it. I confirmed both in the curl output and in Chrome.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;share()&lt;/code&gt; matters too. Both &lt;code&gt;merge&lt;/code&gt; and &lt;code&gt;done$&lt;/code&gt; subscribe to &lt;code&gt;events$&lt;/code&gt;, and &lt;code&gt;share()&lt;/code&gt; makes them use one subscription to the job channel instead of two.&lt;/p&gt;

&lt;h2&gt;
  
  
  The client: close the EventSource when the job ends
&lt;/h2&gt;

&lt;p&gt;This is the mistake I'd most expect to reach production. When the job finishes, the Observable completes and Nest ends the response. To &lt;code&gt;EventSource&lt;/code&gt;, an ended response is a dropped connection, so it waits for the retry delay and connects again.&lt;/p&gt;

&lt;p&gt;I tested this with an endpoint that sent three events and completed, with &lt;code&gt;retry: 300&lt;/code&gt;. In 1.6 seconds Chrome received the same three events three times, plus the first event of a fourth run. For a progress bar that means flicker and wasted requests. For "show a toast when the download is ready", it means the toast fires again and again.&lt;/p&gt;

&lt;p&gt;The fix is on the client: treat the terminal event as the end.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;JobEvent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;progress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;percentage&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="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;complete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;url&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;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;message&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;watchJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&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;onEvent&lt;/span&gt;&lt;span class="p"&gt;:&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="nx"&gt;JobEvent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;source&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;EventSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/jobs/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/events`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;withCredentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&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;event&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;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;JobEvent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;onEvent&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="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="s2"&gt;complete&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&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="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="c1"&gt;// Network blips are retried by the browser. A 404 or 401 closes the stream.&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&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="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;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;readyState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;EventSource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CLOSED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;onEvent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;stream closed&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="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&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;Two more things I checked in Chrome:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Named events skip &lt;code&gt;onmessage&lt;/code&gt;.&lt;/strong&gt; If you set &lt;code&gt;type: "progress"&lt;/code&gt; on the Nest &lt;code&gt;MessageEvent&lt;/code&gt;, the frame goes out as &lt;code&gt;event: progress&lt;/code&gt;, and only &lt;code&gt;addEventListener("progress", ...)&lt;/code&gt; receives it. In my test, &lt;code&gt;onmessage&lt;/code&gt; got the unnamed frame and missed the named one. Either put the type inside &lt;code&gt;data&lt;/code&gt;, as above, or add a listener for every event name you send.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;EventSource&lt;/code&gt; can't send custom headers.&lt;/strong&gt; The constructor takes a URL and a &lt;code&gt;withCredentials&lt;/code&gt; flag, nothing else. If your API uses an &lt;code&gt;X-API-Key&lt;/code&gt; or a bearer token, use a cookie session for the browser, a short-lived signed token in the query string, or &lt;code&gt;fetch()&lt;/code&gt; with a streamed response body and your own frame parser.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Don't open one stream per item: the six-connection limit
&lt;/h2&gt;

&lt;p&gt;Over HTTP/1.1, browsers allow only six connections per host, and each open &lt;code&gt;EventSource&lt;/code&gt; holds one for as long as it lives. Snagbox's controller has a comment about this: batch downloads poll &lt;code&gt;GET /v2/downloads?ids=...&lt;/code&gt; instead of opening one stream per job, because "a 50-item batch would stall on its own event streams".&lt;/p&gt;

&lt;p&gt;I measured how bad it is. A page in headless Chrome 155 opened 8 &lt;code&gt;EventSource&lt;/code&gt;s to my local Nest server over plain HTTP/1.1. Six opened within 5 ms. The other two never opened during the 3-second window. Then the page made an ordinary &lt;code&gt;fetch()&lt;/code&gt; to the same server, and it was still pending after 5 seconds. Six progress streams can freeze the rest of your app's API calls on that host.&lt;/p&gt;

&lt;p&gt;You have three ways out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Serve over HTTP/2.&lt;/strong&gt; All requests share one connection, and the limit becomes the number of concurrent streams the server allows. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource" rel="noopener noreferrer"&gt;MDN's EventSource page&lt;/a&gt; notes that this defaults to 100. If a proxy or CDN in front of your API already speaks HTTP/2 to the browser, you may have this for free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiplex.&lt;/strong&gt; Open one stream per user or per batch and include the job id in every event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poll a batch endpoint for many items.&lt;/strong&gt; This is what Snagbox does for playlists:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;getMany&lt;/span&gt;&lt;span class="p"&gt;(@&lt;/span&gt;&lt;span class="nd"&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;ids&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;ids&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="nf"&gt;map&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;=&amp;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;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&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;id&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;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;store&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="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="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="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unknown&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;One request every second or two for a 50-item batch costs far less than 50 streams, and it can't block anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSE vs polling: what one job costs
&lt;/h2&gt;

&lt;p&gt;I ran one simulated job that lasted 10 seconds and emitted 40 progress updates, one every 250 ms. A Node.js client watched it three ways, and I counted the bytes from the socket itself (&lt;code&gt;socket.bytesRead&lt;/code&gt; and &lt;code&gt;socket.bytesWritten&lt;/code&gt;), so HTTP headers are included. The client and server ran on the same box, over localhost, with the Express adapter.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc0c6aytdwleufoe1u8rr.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc0c6aytdwleufoe1u8rr.jpg" alt="Bar chart of bytes on the wire for one 10-second job with 40 updates: SSE 4,516 bytes in 1 request, polling every 1 s 4,438 bytes in 11 requests, polling every 250 ms 16,506 bytes in 41 requests" width="800" height="360"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Transport&lt;/th&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;th&gt;Updates seen&lt;/th&gt;
&lt;th&gt;Bytes down&lt;/th&gt;
&lt;th&gt;Bytes up&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SSE, one stream&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;40 of 40&lt;/td&gt;
&lt;td&gt;4,437&lt;/td&gt;
&lt;td&gt;79&lt;/td&gt;
&lt;td&gt;4,516&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polling every 1 s&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;10 of 40&lt;/td&gt;
&lt;td&gt;3,525&lt;/td&gt;
&lt;td&gt;913&lt;/td&gt;
&lt;td&gt;4,438&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Polling every 250 ms&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;40 of 40&lt;/td&gt;
&lt;td&gt;13,144&lt;/td&gt;
&lt;td&gt;3,362&lt;/td&gt;
&lt;td&gt;16,506&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Polling once a second used about the same bytes as SSE but showed only a quarter of the updates, so the bar moves in jumps and can show a value up to a second old. Polling fast enough to see every update took 41 requests and 3.7 times the bytes. SSE delivered each update 0.8 ms after the server emitted it on average, with a 2 ms maximum.&lt;/p&gt;

&lt;p&gt;Each SSE update cost about 110 bytes, which is mostly the JSON. Each polled response carried around 320 bytes, because every one repeats the HTTP headers. With API-key auth, a rate limiter and logging on every request, the request count matters more than the bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much memory does an open SSE stream use?
&lt;/h2&gt;

&lt;p&gt;An open stream is a socket, a response object and an RxJS subscription. I opened 1,000 and then 5,000 more concurrent streams against the same job and read the server's resident memory from &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/status&lt;/code&gt;. The client ran in a separate process.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Open streams&lt;/th&gt;
&lt;th&gt;Server RSS before&lt;/th&gt;
&lt;th&gt;RSS after&lt;/th&gt;
&lt;th&gt;Per stream&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;td&gt;75.8 MB&lt;/td&gt;
&lt;td&gt;138.0 MB&lt;/td&gt;
&lt;td&gt;about 64 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;138.7 MB&lt;/td&gt;
&lt;td&gt;305.8 MB&lt;/td&gt;
&lt;td&gt;about 34 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The per-stream figure fell at the larger count, probably because the first batch also paid one-off costs as the heap grew (RSS didn't shrink between the runs). Treat it as a rough range for Nest on Express and Node 22, not a constant. It does show that one instance can hold thousands of idle progress streams. Your file-descriptor limit (&lt;code&gt;ulimit -n&lt;/code&gt;) and proxy timeouts are more likely to stop you first.&lt;/p&gt;

&lt;p&gt;I also counted live subscriptions in the service. With 5,000 streams open, the count was 5,000. 1.5 seconds after the clients disconnected, it was 0. Nest unsubscribes from your Observable when the client goes away, so the RxJS teardown runs and the subject doesn't leak subscribers.&lt;/p&gt;

&lt;p&gt;If your handler does async setup before it returns the Observable, such as opening a database cursor or an LLM session, Nest 12.1.3 also has an &lt;code&gt;@SseSignal()&lt;/code&gt; parameter decorator. It injects an &lt;code&gt;AbortSignal&lt;/code&gt; that fires when the stream ends, so you can release setup resources even if the client left before the Observable existed.&lt;/p&gt;

&lt;h2&gt;
  
  
  A smooth progress bar: never let it go backwards
&lt;/h2&gt;

&lt;p&gt;One Snagbox detail isn't about SSE but shows up in every progress UI. Snagbox runs yt-dlp, which downloads video and audio as two separate passes and reports each from 0 to 100%. Forwarded as-is, the bar fills, snaps back to zero and fills again.&lt;/p&gt;

&lt;p&gt;Snagbox's &lt;a href="https://github.com/fabwaseem/snagbox/blob/main/src/engine/progress.ts" rel="noopener noreferrer"&gt;&lt;code&gt;ProgressAggregator&lt;/code&gt;&lt;/a&gt; fixes this. When downloaded bytes drop, it banks the finished pass. It keeps the percentage monotonic and caps it at 99.9 while the process is alive, because the last pass finishing isn't the job finishing, since the merge still has to run. Only the &lt;code&gt;complete&lt;/code&gt; event reports 100%. If your job has more than one phase, do the same before anything reaches the stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Box: Linux, 8 vCPUs, shared with other workloads. Node.js 22.20.0, &lt;code&gt;@nestjs/core&lt;/code&gt; 12.1.3 on &lt;code&gt;@nestjs/platform-express&lt;/code&gt; 12.1.3, RxJS 7.8.2, TypeScript 5.9, compiled to ESM.&lt;/li&gt;
&lt;li&gt;Jobs were simulated with &lt;code&gt;setInterval&lt;/code&gt;, so the tests measure the transport and Nest, not a real download.&lt;/li&gt;
&lt;li&gt;Bytes came from the client socket's counters, with keep-alive for the polling client.&lt;/li&gt;
&lt;li&gt;The late-joiner test ran five times per subject type, joining 5.0 to 5.5 s into a job that emitted every 2 s.&lt;/li&gt;
&lt;li&gt;Browser tests used &lt;code&gt;puppeteer-core&lt;/code&gt; driving Chrome 155 headless, against &lt;code&gt;http://127.0.0.1&lt;/code&gt; over HTTP/1.1.&lt;/li&gt;
&lt;li&gt;Memory is VmRSS of the Nest process, read before opening streams and 4 seconds after the last one opened.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are single-machine, localhost numbers. Over a real network, polling gets worse, because each request pays the round trip, while SSE pays it once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes with NestJS SSE
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not closing the &lt;code&gt;EventSource&lt;/code&gt; on the terminal event.&lt;/strong&gt; The browser reconnects and replays the job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relying on Nest's automatic ids.&lt;/strong&gt; They restart at 1 on every connection. Use your own sequence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sending &lt;code&gt;type&lt;/code&gt; on the &lt;code&gt;MessageEvent&lt;/code&gt; and listening with &lt;code&gt;onmessage&lt;/code&gt;.&lt;/strong&gt; Named events only reach &lt;code&gt;addEventListener&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using a plain &lt;code&gt;Subject&lt;/code&gt;.&lt;/strong&gt; Late clients wait for the next update. Use &lt;code&gt;ReplaySubject(1)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One stream per list item over HTTP/1.1.&lt;/strong&gt; Six streams block the host. Poll a batch endpoint or use HTTP/2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No heartbeat behind a proxy.&lt;/strong&gt; Idle streams get cut. Send a comment frame every 15 to 30 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Putting the API key in a header the browser can't send.&lt;/strong&gt; &lt;code&gt;EventSource&lt;/code&gt; has no headers option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keeping job channels forever.&lt;/strong&gt; Delete them a few minutes after the terminal event, as Snagbox does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the queue side of long-running work, read &lt;a href="https://www.fabwaseem.com/blog/ai-automation-pipeline-nodejs-bullmq" rel="noopener noreferrer"&gt;Building an AI automation pipeline in Node.js with BullMQ&lt;/a&gt;. If you're on an older Nest version, &lt;a href="https://www.fabwaseem.com/blog/nestjs-12" rel="noopener noreferrer"&gt;the NestJS 12 migration guide&lt;/a&gt; covers the upgrade. For a secure API base to put this in, see &lt;a href="https://www.fabwaseem.com/blog/secure-nestjs-prisma-api-starter" rel="noopener noreferrer"&gt;my NestJS + Prisma API starter&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;NestJS streams SSE from any &lt;code&gt;@Sse()&lt;/code&gt; route that returns an Observable, and sets &lt;code&gt;text/event-stream&lt;/code&gt;, no-cache and &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; for you.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;ReplaySubject(1)&lt;/code&gt; per job gave late clients their first frame in 2 to 4 ms, against up to 1,002 ms with a plain &lt;code&gt;Subject&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;id&lt;/code&gt; from your job's sequence number. Nest's automatic ids restart at 1 on every reconnect.&lt;/li&gt;
&lt;li&gt;Close the &lt;code&gt;EventSource&lt;/code&gt; on the terminal event. A completed stream made Chrome reconnect and replay it three times in 1.6 s.&lt;/li&gt;
&lt;li&gt;Send heartbeats as &lt;code&gt;{ comment: "ping" }&lt;/code&gt;, which the browser ignores and Nest leaves without an id.&lt;/li&gt;
&lt;li&gt;Over HTTP/1.1, Chrome opened 6 of 8 streams and then stalled a normal &lt;code&gt;fetch()&lt;/code&gt;. Use HTTP/2, one multiplexed stream, or a batch polling endpoint.&lt;/li&gt;
&lt;li&gt;For one 10-second job, SSE used 1 request and 4,516 bytes. Polling every 250 ms needed 41 requests and 16,506 bytes for the same updates.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/nestjs-server-sent-events" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;, where I write about Node.js, NestJS, Next.js and AI automation.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nestjs</category>
      <category>node</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Prisma 8 vs Prisma 7: Should You Upgrade? A Tested Migration Guide</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Sun, 18 Oct 2026 18:52:09 +0000</pubDate>
      <link>https://dev.to/fabwaseem/prisma-8-vs-prisma-7-should-you-upgrade-a-tested-migration-guide-p5b</link>
      <guid>https://dev.to/fabwaseem/prisma-8-vs-prisma-7-should-you-upgrade-a-tested-migration-guide-p5b</guid>
      <description>&lt;p&gt;Heads up if any of your projects, CI jobs or Dockerfiles run &lt;code&gt;npm install prisma&lt;/code&gt; without a version pin: since August 2026 that installs a Prisma 8 release candidate. I migrated a Prisma 7 app on PostgreSQL 17 side by side, and this is what I found.&lt;/p&gt;

&lt;p&gt;Prisma 8 is a rewrite of Prisma ORM, not a version bump. The schema becomes a &lt;strong&gt;contract&lt;/strong&gt;, &lt;code&gt;prisma generate&lt;/code&gt; becomes &lt;code&gt;prisma contract emit&lt;/code&gt;, &lt;code&gt;@prisma/client&lt;/code&gt; is replaced by &lt;code&gt;@prisma/orm-postgres&lt;/code&gt;, and queries are chained (&lt;code&gt;db.orm.public.User.where(...).all()&lt;/code&gt;). It is still a release candidate, but since August 2026 &lt;code&gt;npm install prisma&lt;/code&gt; installs it.&lt;/p&gt;

&lt;p&gt;That last fact is the urgent part. Any Prisma 7 project, CI job or Dockerfile that installs &lt;code&gt;prisma&lt;/code&gt; without a version now gets a CLI with no &lt;code&gt;generate&lt;/code&gt;, &lt;code&gt;migrate&lt;/code&gt; or &lt;code&gt;db push&lt;/code&gt;. I upgraded a real Prisma 7 app on PostgreSQL 17 with the official side-by-side path, and this guide gives the commands, the output, two gotchas the docs don't spell out, and a clear answer on whether to move now. Facts are cited to the &lt;a href="https://www.prisma.io/docs/orm/release-status" rel="noopener noreferrer"&gt;Prisma ORM release status page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Prisma 8?
&lt;/h2&gt;

&lt;p&gt;Prisma 8 (Prisma ORM 8) is the next major version of Prisma ORM, a typed data layer for TypeScript. You describe models in a contract file, &lt;code&gt;prisma contract emit&lt;/code&gt; writes &lt;code&gt;contract.json&lt;/code&gt; and &lt;code&gt;contract.d.ts&lt;/code&gt;, and you query through a &lt;code&gt;db&lt;/code&gt; client from &lt;code&gt;@prisma/orm-postgres&lt;/code&gt;. There is no generated &lt;code&gt;PrismaClient&lt;/code&gt;, and migrations are TypeScript files you can review.&lt;/p&gt;

&lt;p&gt;Key facts on October 17, 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Prisma ORM 7&lt;/th&gt;
&lt;th&gt;Prisma ORM 8&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;npm tag&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;prisma@prev&lt;/code&gt; (7.10.1)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;prisma@latest&lt;/code&gt; (8.0.0-rc.23)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status&lt;/td&gt;
&lt;td&gt;Stable, supported&lt;/td&gt;
&lt;td&gt;Release candidate, GA "expected in October 2026"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime package&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@prisma/client&lt;/code&gt; + a driver adapter&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;@prisma/orm-postgres&lt;/code&gt; (8.0.0-rc.18)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema file&lt;/td&gt;
&lt;td&gt;&lt;code&gt;schema.prisma&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;contract.prisma&lt;/code&gt; (first line &lt;code&gt;// use prisma-8&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codegen&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prisma generate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prisma contract emit&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queries&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prisma.user.findMany({ where })&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;db.orm.public.User.where(...).all()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrations&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;migrate dev&lt;/code&gt; / &lt;code&gt;migrate deploy&lt;/code&gt; (SQL)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;migration plan&lt;/code&gt; / &lt;code&gt;db migrate&lt;/code&gt; (TypeScript)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Databases&lt;/td&gt;
&lt;td&gt;PostgreSQL, MySQL, SQL Server, SQLite, MongoDB, CockroachDB&lt;/td&gt;
&lt;td&gt;PostgreSQL; MongoDB early access; SQLite experimental&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node.js&lt;/td&gt;
&lt;td&gt;20.19+, 22.12+ or 24+&lt;/td&gt;
&lt;td&gt;22.18+, 24.11+ or 26+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;td&gt;5.4+&lt;/td&gt;
&lt;td&gt;5.9+&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The CLI and the library are versioned separately, so &lt;code&gt;prisma&lt;/code&gt; 8.0.0-rc.23 and &lt;code&gt;@prisma/orm-postgres&lt;/code&gt; 8.0.0-rc.18 are a matching pair. Prisma says "any two &lt;code&gt;latest&lt;/code&gt; versions work together."&lt;/p&gt;

&lt;h2&gt;
  
  
  Is Prisma 8 stable? When is the release date?
&lt;/h2&gt;

&lt;p&gt;No, not yet. Prisma 8 is a release candidate, and Prisma's release status page says general availability "is expected in October 2026." On October 17, 2026 the newest builds were &lt;code&gt;prisma&lt;/code&gt; 8.0.0-rc.23 (published October 15) and &lt;code&gt;@prisma/orm-postgres&lt;/code&gt; 8.0.0-rc.18 (October 14). Prisma warns that "some details of the API may still change."&lt;/p&gt;

&lt;p&gt;Two support dates follow from the release:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prisma ORM 7&lt;/strong&gt; keeps receiving bug fixes and security updates "for 18 months from the day Prisma ORM 8 reaches general availability."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prisma ORM 6&lt;/strong&gt; receives security patches only until &lt;strong&gt;November 19, 2026&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So there is no rush to leave Prisma 7, but Prisma 6 projects should move to 7 now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does &lt;code&gt;npm install prisma&lt;/code&gt; install Prisma 8?
&lt;/h2&gt;

&lt;p&gt;Because Prisma moved the npm &lt;code&gt;latest&lt;/code&gt; tag to Prisma 8 while it is still an RC. Prisma's &lt;a href="https://www.prisma.io/changelog/2026-08-28" rel="noopener noreferrer"&gt;August 28 changelog&lt;/a&gt; says the release "makes Prisma 8 available through &lt;a href="mailto:prisma@latest"&gt;prisma@latest&lt;/a&gt;." Prisma 7 is now &lt;code&gt;prisma@prev&lt;/code&gt;. A Prisma 7 project that installs &lt;code&gt;prisma&lt;/code&gt; without a version range gets a CLI that cannot read its workflow.&lt;/p&gt;

&lt;p&gt;I ran the old commands with the Prisma 8 CLI in a Prisma 7 project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npx prisma generate
✘ [CLI.UNKNOWN_COMMAND] No command registered for `generate`
$ npx prisma migrate deploy
✘ [CLI.UNKNOWN_COMMAND] No command registered for `migrate`, did you mean `migration`?
$ npx prisma db push
✘ [CLI.UNKNOWN_COMMAND] No command registered for `push`, did you mean `update`?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Do this today, even if you never upgrade:&lt;/strong&gt; pin both packages, then grep your CI, Dockerfiles and scripts for &lt;code&gt;npx prisma&lt;/code&gt; and &lt;code&gt;prisma@latest&lt;/code&gt;.&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;"devDependencies"&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;"prisma"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^7"&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;"dependencies"&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;"@prisma/client"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^7"&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;Anywhere a command runs without a lockfile, write &lt;code&gt;npx prisma@7 migrate deploy&lt;/code&gt; instead of &lt;code&gt;npx prisma migrate deploy&lt;/code&gt;. If you build images with a multi-stage Dockerfile like the one in my &lt;a href="https://www.fabwaseem.com/blog/nextjs-dockerfile" rel="noopener noreferrer"&gt;Next.js Dockerfile guide&lt;/a&gt;, the &lt;code&gt;npm ci&lt;/code&gt; step respects the lockfile; a separate &lt;code&gt;npx prisma ...&lt;/code&gt; line in a later stage does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in Prisma 8?
&lt;/h2&gt;

&lt;p&gt;Almost everything you type. The schema is a contract, codegen is &lt;code&gt;contract emit&lt;/code&gt;, the client is built in &lt;code&gt;src/prisma/db.ts&lt;/code&gt;, queries are chained, migrations are TypeScript packages tracked by a hash marker in the database, and several Prisma 7 features (&lt;code&gt;$extends&lt;/code&gt;, nested updates, &lt;code&gt;increment&lt;/code&gt;, JSON filters, &lt;code&gt;P2002&lt;/code&gt; codes) are missing. Your database tables and data do not change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Commands
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prisma 7&lt;/th&gt;
&lt;th&gt;Prisma 8&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prisma generate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prisma contract emit&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prisma migrate dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;prisma db update&lt;/code&gt;, or &lt;code&gt;prisma migration plan&lt;/code&gt; + &lt;code&gt;prisma db migrate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prisma migrate deploy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prisma db migrate&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prisma db push&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;prisma db init&lt;/code&gt; (empty database), then &lt;code&gt;prisma db update&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prisma db pull&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prisma contract infer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prisma format&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;prisma contract format&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;prisma migrate reset&lt;/code&gt;, &lt;code&gt;prisma db seed&lt;/code&gt;, &lt;code&gt;prisma studio&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;None in the Prisma 8 CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Queries
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prisma 7&lt;/th&gt;
&lt;th&gt;Prisma 8&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;prisma.user.findMany({ where: { active: true } })&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;db.orm.public.User.where({ active: true }).all()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;findUnique&lt;/code&gt; / &lt;code&gt;findFirst&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.where(...).first()&lt;/code&gt; (returns &lt;code&gt;null&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;select: { id: true }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.select("id")&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;include: { posts: true }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.include("posts")&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orderBy: { id: "asc" }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.orderBy((u) =&amp;gt; u.id.asc())&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;take&lt;/code&gt; / &lt;code&gt;skip&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.limit(n)&lt;/code&gt; / &lt;code&gt;.offset(n)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;create({ data: {...} })&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.create({...})&lt;/code&gt; (no &lt;code&gt;data&lt;/code&gt; wrapper)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;contains: "ali"&lt;/code&gt; / &lt;code&gt;mode: "insensitive"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.like("%ali%")&lt;/code&gt; / &lt;code&gt;.ilike("%ali%")&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;$transaction(async (tx) =&amp;gt; ...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;db.transaction(async (tx) =&amp;gt; ...)&lt;/code&gt; with &lt;code&gt;tx.orm&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;$disconnect()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;db.close()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;public&lt;/code&gt; is the PostgreSQL schema the table lives in. The full mapping is in Prisma's &lt;a href="https://www.prisma.io/docs/orm/coming-from-prisma-orm-7" rel="noopener noreferrer"&gt;Coming from Prisma ORM 7&lt;/a&gt; reference.&lt;/p&gt;

&lt;h3&gt;
  
  
  Schema types
&lt;/h3&gt;

&lt;p&gt;In a hand-written contract, &lt;code&gt;@db.&lt;/code&gt; attributes become the field type (&lt;code&gt;String @db.VarChar(255)&lt;/code&gt; is &lt;code&gt;VarChar(255)&lt;/code&gt;), &lt;code&gt;Json&lt;/code&gt; must be written &lt;code&gt;Jsonb&lt;/code&gt;, &lt;code&gt;@updatedAt&lt;/code&gt; is &lt;code&gt;temporal.updatedAt()&lt;/code&gt;, &lt;code&gt;cuid()&lt;/code&gt; is &lt;code&gt;cuid(2)&lt;/code&gt;, and implicit many-to-many relations need an explicit join model. &lt;code&gt;DateTime&lt;/code&gt; values come back as &lt;code&gt;Temporal.Instant&lt;/code&gt;, which Node.js 22 and 24 do not have built in. On my Node.js 24.22.0, &lt;code&gt;node -p "typeof Temporal"&lt;/code&gt; printed &lt;code&gt;undefined&lt;/code&gt;, so a hand-written contract needs &lt;code&gt;temporal-polyfill&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is missing in Prisma 8?
&lt;/h2&gt;

&lt;p&gt;Prisma 8 does not yet have several Prisma 7 features. The release status page lists &lt;code&gt;$extends&lt;/code&gt;, "filtering inside JSON columns, atomic &lt;code&gt;increment&lt;/code&gt;, most nested writes, transaction isolation levels, and the &lt;code&gt;P2002&lt;/code&gt;-style error codes." &lt;code&gt;$extends&lt;/code&gt; will not return; middleware, custom collections and scopes replace it. If your app relies on these, stay on Prisma 7 for now.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Prisma 7 feature&lt;/th&gt;
&lt;th&gt;In Prisma 8&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;$extends&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Will not be added. Use middleware, &lt;code&gt;Collection&lt;/code&gt; subclasses or scopes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{ increment: 1 }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Not available. Use raw SQL or read and write in a transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JSON path filters&lt;/td&gt;
&lt;td&gt;Not available. &lt;code&gt;Jsonb&lt;/code&gt; compares as a whole; use raw SQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;connectOrCreate&lt;/code&gt;, nested &lt;code&gt;update&lt;/code&gt;/&lt;code&gt;upsert&lt;/code&gt;/&lt;code&gt;delete&lt;/code&gt;, &lt;code&gt;set&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Not available. Only nested &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;connect&lt;/code&gt;, &lt;code&gt;disconnect&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;isolationLevel&lt;/code&gt;, &lt;code&gt;timeout&lt;/code&gt;, nested transactions&lt;/td&gt;
&lt;td&gt;Not available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;P2002&lt;/code&gt; / &lt;code&gt;P2025&lt;/code&gt; error codes&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;error.sqlState&lt;/code&gt; (&lt;code&gt;23505&lt;/code&gt; = unique violation) or codes like &lt;code&gt;RUNTIME.NO_ROWS&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;omit&lt;/code&gt;, &lt;code&gt;relationLoadStrategy&lt;/code&gt;, TypedSQL, read replicas&lt;/td&gt;
&lt;td&gt;Not available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;has&lt;/code&gt; / &lt;code&gt;hasSome&lt;/code&gt; on &lt;code&gt;String[]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Not available. The field works; filter with raw SQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL, SQL Server, CockroachDB&lt;/td&gt;
&lt;td&gt;Not yet. Stay on Prisma 7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I replaced an &lt;code&gt;increment&lt;/code&gt; with the documented raw SQL builder, and it ran on 8.0.0-rc.18:&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;db&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;./prisma/db.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Post&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;bump&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`UPDATE "Post" SET views = views + 1 WHERE id = &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; RETURNING id, views`&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returnsRow&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="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;columns&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="na"&gt;views&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;views&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&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;rows&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;runtime&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bump&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// [ { id: 1, views: 3 } ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I migrate from Prisma 7 to Prisma 8?
&lt;/h2&gt;

&lt;p&gt;Run both versions side by side on the same database. One command (&lt;code&gt;npx prisma@latest orm init --from-prisma7-schema prisma/schema.prisma&lt;/code&gt;) renames Prisma 7 to &lt;code&gt;prisma7&lt;/code&gt;, installs Prisma 8, and lets it read your existing &lt;code&gt;schema.prisma&lt;/code&gt;. You then sign the database, move routes one at a time, and hand migrations to Prisma 8 only when you choose.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmgroggk6ev221hvisdpv.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmgroggk6ev221hvisdpv.jpg" alt="The five phases of a Prisma 7 to Prisma 8 migration: prepare Prisma 7 as prisma7, add Prisma 8, move routes one at a time, transfer migration ownership with db sign and migration plan, then remove Prisma 7" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the path from Prisma's official &lt;a href="https://www.prisma.io/docs/guides/upgrade-prisma-orm/postgresql" rel="noopener noreferrer"&gt;Prisma ORM 7 to 8 guide&lt;/a&gt;, and the &lt;a href="https://github.com/prisma/prisma8-and-7-example" rel="noopener noreferrer"&gt;prisma8-and-7-example&lt;/a&gt; repo shows each phase. My test app was a Hono API on Prisma 7.10.1 with &lt;code&gt;@prisma/adapter-pg&lt;/code&gt;, PostgreSQL 17.11, Node.js 24.22.0, a &lt;code&gt;Role&lt;/code&gt; enum, a &lt;code&gt;DateTime @default(now())&lt;/code&gt;, a &lt;code&gt;Json?&lt;/code&gt; column and an &lt;code&gt;increment&lt;/code&gt; update.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Run orm init
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx prisma@latest orm init &lt;span class="nt"&gt;--from-prisma7-schema&lt;/span&gt; prisma/schema.prisma
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write &lt;code&gt;prisma@latest&lt;/code&gt; in full, because inside a Prisma 7 project &lt;code&gt;npx prisma&lt;/code&gt; still runs version 7. In CI or from a coding agent, the command stops and asks for consent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CLI.CONSENT_REQUIRED: "Prisma 7 is installed as `prisma`. Keep it as @prisma/prisma7
(binary prisma7) and move `prisma` to Prisma 8, and rename prisma.config.ts to
prisma7.config.ts?" requires explicit consent, and the session is not interactive.
Grant it by passing --confirm app.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that it had already added &lt;code&gt;@prisma/orm-postgres&lt;/code&gt; to &lt;code&gt;package.json&lt;/code&gt; before it stopped. With &lt;code&gt;--confirm app&lt;/code&gt; it finished and changed exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;   "dependencies": {
     "@prisma/adapter-pg": "^7.10.1",
     "@prisma/client": "^7.10.1",
&lt;span class="gi"&gt;+    "@prisma/orm-postgres": "^8.0.0-rc.18",
&lt;/span&gt;   },
   "devDependencies": {
&lt;span class="gi"&gt;+    "@prisma/cli-engine": "^0.7.0",
+    "@prisma/prisma7": "^7.10.1",
&lt;/span&gt;&lt;span class="gd"&gt;-    "prisma": "^7.10.1",
&lt;/span&gt;&lt;span class="gi"&gt;+    "prisma": "^8.0.0-rc.23",
&lt;/span&gt;   }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It renamed &lt;code&gt;prisma.config.ts&lt;/code&gt; to &lt;code&gt;prisma7.config.ts&lt;/code&gt; (now importing &lt;code&gt;@prisma/prisma7/config&lt;/code&gt;), wrote a new &lt;code&gt;prisma.config.ts&lt;/code&gt;, and emitted the client into &lt;code&gt;src/prisma/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// prisma.config.ts (written by orm init)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dotenv/config&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;definePrismaConfig&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;@prisma/cli-engine&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;defineConfig&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ormConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prisma7Schema&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;@prisma/orm-postgres/config&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;default&lt;/span&gt; &lt;span class="nf"&gt;definePrismaConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;orm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;ormConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;prisma7Schema&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prisma/schema.prisma&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;src/prisma&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;connection&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;npx prisma --version&lt;/code&gt; now reports 8.0.0-rc.23, and &lt;code&gt;npx prisma7 generate&lt;/code&gt; still writes the Prisma 7 client. Update every script and CI step that must keep using Prisma 7 to call &lt;code&gt;prisma7&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Sign the database
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npx prisma db sign
✔ app: signed b3b45c6c5fd8… (no marker before)
✔ Database signed
✔ Advanced ref "db" → b3b45c6c5fd8…

$ npx prisma db verify
✔ Database marker and schema match contract
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;db sign&lt;/code&gt; changes no tables. It adds a &lt;code&gt;prisma_contract.marker&lt;/code&gt; table that records which contract hash the database satisfies. Prisma 7 still owns migrations, so after every &lt;code&gt;prisma7 migrate dev&lt;/code&gt; or &lt;code&gt;prisma7 migrate deploy&lt;/code&gt;, run &lt;code&gt;npx prisma contract emit&lt;/code&gt; and &lt;code&gt;npx prisma db sign&lt;/code&gt; again.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Move one route
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/routes/users.ts — now on Prisma 8&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;db&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;../prisma/db.ts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;users&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;/&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="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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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="nx"&gt;orm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;public&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="nf"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;posts&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;posts&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;posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orderBy&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;post&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;post&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;asc&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orderBy&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="o"&gt;=&amp;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;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asc&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&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;c&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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&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="k"&gt;async &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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&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;c&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;json&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;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="nl"&gt;name&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="k"&gt;try&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;orm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;public&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="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// No P2002 in Prisma 8: check the PostgreSQL SQL state instead&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;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sqlState&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&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="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sqlState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;23505&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;c&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="na"&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;email already exists&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;409&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="nx"&gt;error&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 posts route stayed on Prisma 7, including its &lt;code&gt;increment&lt;/code&gt;. A post written through Prisma 7 appeared in the next Prisma 8 read, and a duplicate email returned my 409. &lt;code&gt;tsc --noEmit&lt;/code&gt; passed on TypeScript 6.0.3 and, as a bonus, on &lt;a href="https://www.fabwaseem.com/blog/typescript-7" rel="noopener noreferrer"&gt;TypeScript 7&lt;/a&gt; 7.0.2.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Hand migrations to Prisma 8
&lt;/h3&gt;

&lt;p&gt;When you are ready, convert the Prisma 7 schema into a real contract, point the config at it, sign, then plan a change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx prisma contract print &lt;span class="nt"&gt;--output&lt;/span&gt; prisma8/contract.prisma
&lt;span class="c"&gt;# set contract: "prisma8/contract.prisma" in prisma.config.ts&lt;/span&gt;
npx prisma contract emit
npx prisma db sign
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The printed contract produced the &lt;strong&gt;same hash&lt;/strong&gt; as the Prisma 7 schema (&lt;code&gt;unchanged, already signed&lt;/code&gt;), so nothing drifted. It mapped my types like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// use prisma-8
namespace public {
  model User {
    id        Int                @id @default(autoincrement())
    email     String
    name      String?
    role      pg.enum(Role)      @default("USER")
    createdAt TimestampString(3) @default(now())
    posts     Post[]

    @@index([email], map: "User_email_key", unique: true)
  }

  native_enum Role {
    USER = "USER"
    ADMIN = "ADMIN"
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I added &lt;code&gt;bio String?&lt;/code&gt; and ran the new migration loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ npx prisma contract emit
$ npx prisma migration plan --name add_user_bio
→ Review migrations/app/20261017T1408_baseline and migrations/app/20261017T1409_add_user_bio
$ npx prisma db migrate --advance-ref db
✔ Applied 1 migration(s) (1 operation(s)) across 1 contract space(s)
├─ Add column "bio" to "User"
$ npx prisma db verify
✔ Database marker and schema match contract
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first plan writes a baseline (the schema Prisma 7 built) plus your change. &lt;code&gt;db migrate&lt;/code&gt; skips the baseline because the marker already records it. Each migration is a reviewable &lt;code&gt;migration.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;override&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="nf"&gt;operations&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addColumn&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;table&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="na"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;col&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bio&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;text&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;codecRef&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;codecId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pg/text@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="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 Prisma 7 routes kept working after the new column. From here, retire &lt;code&gt;prisma7 migrate&lt;/code&gt; from your scripts, move the remaining routes, and finally remove &lt;code&gt;@prisma/prisma7&lt;/code&gt;, &lt;code&gt;@prisma/client&lt;/code&gt; and &lt;code&gt;@prisma/adapter-pg&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke when I ran Prisma 7 and 8 side by side?
&lt;/h2&gt;

&lt;p&gt;Two things the guide doesn't warn about. First, &lt;code&gt;orm init&lt;/code&gt; needs &lt;code&gt;--confirm app&lt;/code&gt; in any non-interactive shell (CI, Docker, coding agents). Second, &lt;code&gt;createdAt&lt;/code&gt; timestamps written by Prisma 8 were 5 hours off. Prisma 7 fills &lt;code&gt;@default(now())&lt;/code&gt; in the client in UTC, but Prisma 8 leaves it to the database, which used my server's local time zone.&lt;/p&gt;

&lt;p&gt;The evidence, from the same &lt;code&gt;User&lt;/code&gt; table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;id | createdAt                 written by
 1 | 2026-10-17 14:06:44.701   Prisma 7 (UTC)
 2 | 2026-10-17 19:08:52.282   Prisma 8 (server time zone Asia/Karachi, UTC+5)
 4 | 2026-10-17 14:09:08.795   Prisma 7 (UTC)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prisma 7 created the column as &lt;code&gt;TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP&lt;/code&gt;, which is &lt;code&gt;timestamp without time zone&lt;/code&gt;. Prisma's own mapping table says &lt;code&gt;@default(now())&lt;/code&gt; in Prisma 8 "is a database default." For a &lt;code&gt;timestamp&lt;/code&gt; column, PostgreSQL then writes the time in the session's &lt;code&gt;TimeZone&lt;/code&gt; setting, which defaults to the server's. After &lt;code&gt;ALTER DATABASE p8demo SET timezone TO 'UTC'&lt;/code&gt;, Prisma 8 wrote 14:09:20 at 14:09:20 UTC.&lt;/p&gt;

&lt;p&gt;Many managed PostgreSQL services default to UTC, so you may never see this. Check before mixing writers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- must be UTC (or Etc/UTC) while Prisma 7 and 8 both write&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also note the data type change: read through &lt;code&gt;prisma7Schema(...)&lt;/code&gt;, &lt;code&gt;DateTime&lt;/code&gt; comes back as a string such as &lt;code&gt;"2026-10-17 14:06:44.701"&lt;/code&gt;, not a &lt;code&gt;Date&lt;/code&gt;. My JSON responses changed format between the two routes (&lt;code&gt;2026-10-17T14:06:44.701Z&lt;/code&gt; from Prisma 7), so API clients that parse dates should be tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I upgrade to Prisma 8 now?
&lt;/h2&gt;

&lt;p&gt;Not for production apps yet. Pin Prisma 7 today, because that protects you from the &lt;code&gt;latest&lt;/code&gt; tag change, and plan the move after GA. Start with Prisma 8 now only for new PostgreSQL projects that don't need the missing features, or to trial it on one route of an existing app with the side-by-side setup.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8ahsiuuf9y39yfpk0805.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8ahsiuuf9y39yfpk0805.jpg" alt="Decision flow for Prisma 8: stay on Prisma 7 for MySQL, SQL Server or CockroachDB, or if you use $extends, nested updates, increment, JSON filters or P2002 codes; otherwise trial Prisma 8 side by side or start new PostgreSQL projects on it" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prisma 6&lt;/td&gt;
&lt;td&gt;Upgrade to Prisma 7 before November 19, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prisma 7 on MySQL, SQL Server or CockroachDB&lt;/td&gt;
&lt;td&gt;Stay on 7; Prisma 8 doesn't support them yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prisma 7 using &lt;code&gt;$extends&lt;/code&gt;, nested updates, JSON filters or &lt;code&gt;P2002&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Stay on 7 until those have replacements you accept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prisma 7 on PostgreSQL, simple CRUD&lt;/td&gt;
&lt;td&gt;Pin 7 now; trial 8 on one route after GA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New PostgreSQL project&lt;/td&gt;
&lt;td&gt;Prisma 8 is reasonable; expect small API edits until GA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Using Accelerate&lt;/td&gt;
&lt;td&gt;Switch connections before December 1, 2026 regardless&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What happens to Prisma Accelerate?
&lt;/h2&gt;

&lt;p&gt;Prisma retires Accelerate on &lt;strong&gt;December 1, 2026&lt;/strong&gt;, according to its &lt;a href="https://www.prisma.io/changelog/2026-10-02" rel="noopener noreferrer"&gt;October 2 changelog&lt;/a&gt;. If your connection string starts with &lt;code&gt;prisma+postgres://accelerate.prisma-data.net&lt;/code&gt;, switch to a pooled connection or the serverless driver before then. Accelerate's query caching has no replacement, so remove &lt;code&gt;cacheStrategy&lt;/code&gt; from your queries first.&lt;/p&gt;

&lt;p&gt;The same changelog sets a second deadline: Prisma Postgres Early Access databases, the ones still on PostgreSQL 16, "are deleted on November 1, 2026 at 00:00 UTC." Move each one to a new database before then. Both deadlines apply whether you stay on Prisma 7 or move to 8.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prisma 8 upgrade checklist
&lt;/h2&gt;

&lt;p&gt;Pin Prisma 7 first, then handle the deadlines, then migrate on your own schedule. These steps follow the official guide plus what I hit in testing.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pin &lt;code&gt;prisma&lt;/code&gt; and &lt;code&gt;@prisma/client&lt;/code&gt; to &lt;code&gt;^7&lt;/code&gt;&lt;/strong&gt; and replace every unpinned &lt;code&gt;npx prisma&lt;/code&gt; in CI and Dockerfiles with &lt;code&gt;npx prisma@7&lt;/code&gt; or a lockfile install.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave Accelerate&lt;/strong&gt; before December 1, 2026, and move Early Access Prisma Postgres databases before November 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check requirements&lt;/strong&gt;: Node.js 22.18+, 24.11+ or 26+, TypeScript 5.9+ with &lt;code&gt;strict&lt;/code&gt;, PostgreSQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit for missing features&lt;/strong&gt;: &lt;code&gt;$extends&lt;/code&gt;, &lt;code&gt;increment&lt;/code&gt;, nested updates, JSON filters, isolation levels, &lt;code&gt;P2002&lt;/code&gt; handling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the database time zone&lt;/strong&gt; with &lt;code&gt;SHOW timezone;&lt;/code&gt; and set it to UTC if Prisma 7 and 8 will both write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run &lt;code&gt;npx prisma@latest orm init --from-prisma7-schema prisma/schema.prisma&lt;/code&gt;&lt;/strong&gt; (add &lt;code&gt;--confirm app&lt;/code&gt; in CI).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run &lt;code&gt;npx prisma db sign&lt;/code&gt;&lt;/strong&gt;, then move one low-risk route to &lt;code&gt;db.orm&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After each &lt;code&gt;prisma7&lt;/code&gt; migration&lt;/strong&gt;, run &lt;code&gt;prisma contract emit&lt;/code&gt; and &lt;code&gt;prisma db sign&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transfer migrations&lt;/strong&gt; with &lt;code&gt;contract print&lt;/code&gt;, &lt;code&gt;db sign&lt;/code&gt;, &lt;code&gt;migration plan&lt;/code&gt;, &lt;code&gt;db migrate --advance-ref db&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove Prisma 7&lt;/strong&gt; when nothing imports &lt;code&gt;@prisma/client&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Common Prisma 8 mistakes
&lt;/h2&gt;

&lt;p&gt;The most common mistakes are letting &lt;code&gt;prisma@latest&lt;/code&gt; slip into a Prisma 7 build, running Prisma 8's &lt;code&gt;db init&lt;/code&gt; or &lt;code&gt;db update&lt;/code&gt; against a database Prisma 7 already manages, forgetting to re-sign after a Prisma 7 migration, and assuming &lt;code&gt;DateTime&lt;/code&gt; and error handling behave the same in both clients.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mistake&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unpinned &lt;code&gt;prisma&lt;/code&gt; in CI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;No command registered for generate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pin &lt;code&gt;^7&lt;/code&gt; or use &lt;code&gt;npx prisma@7&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;orm init&lt;/code&gt; in CI without consent&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CLI.CONSENT_REQUIRED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;--confirm app&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;db init&lt;/code&gt; / &lt;code&gt;db update&lt;/code&gt; on a Prisma 7 database&lt;/td&gt;
&lt;td&gt;Skips the signed handoff; the guide says to run neither&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;db sign&lt;/code&gt;, then the phase 4 handoff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not re-signing after &lt;code&gt;prisma7 migrate&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;db verify&lt;/code&gt; reports a contract mismatch&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;contract emit&lt;/code&gt;, then &lt;code&gt;db sign&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catching &lt;code&gt;P2002&lt;/code&gt; in moved routes&lt;/td&gt;
&lt;td&gt;Unique violations crash as 500s&lt;/td&gt;
&lt;td&gt;Check &lt;code&gt;error.sqlState === "23505"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server time zone not UTC&lt;/td&gt;
&lt;td&gt;Prisma 8 timestamps offset by hours&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ALTER DATABASE ... SET timezone TO 'UTC'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expecting &lt;code&gt;Date&lt;/code&gt; objects&lt;/td&gt;
&lt;td&gt;Strings or &lt;code&gt;Temporal.Instant&lt;/code&gt; instead&lt;/td&gt;
&lt;td&gt;Convert at the API boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hand-written contract on Node.js 24&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Temporal&lt;/code&gt; is undefined&lt;/td&gt;
&lt;td&gt;&lt;code&gt;import "temporal-polyfill/full/global"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; I benchmarked Prisma 8 RC against Prisma 7 and Drizzle in &lt;a href="https://www.fabwaseem.com/blog/drizzle-vs-prisma" rel="noopener noreferrer"&gt;Drizzle vs Prisma in 2026&lt;/a&gt;: Prisma 8 RC had the slowest bundled cold start (about 370 ms). If you use Better Auth, note that Better Auth 1.7 supports Prisma 5 to 7 but not Prisma 8 yet; see my &lt;a href="https://www.fabwaseem.com/blog/better-auth-nextjs" rel="noopener noreferrer"&gt;Better Auth with Next.js 16 setup&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; Prisma's &lt;code&gt;@default(uuid(7))&lt;/code&gt; generates IDs in the client and sets no database default. On PostgreSQL 18 you can use &lt;code&gt;dbgenerated("uuidv7()")&lt;/code&gt; instead; see my &lt;a href="https://www.fabwaseem.com/blog/uuidv7-postgres" rel="noopener noreferrer"&gt;UUIDv7 in PostgreSQL 18 benchmark&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Prisma 8 is a release candidate (prisma 8.0.0-rc.23, @prisma/orm-postgres 8.0.0-rc.18 on October 17, 2026) with GA expected in October 2026, but npm's latest tag already installs it.&lt;/li&gt;
&lt;li&gt;Pin prisma and @prisma/client to ^7 today; the Prisma 8 CLI has no generate, migrate or db push.&lt;/li&gt;
&lt;li&gt;Prisma 8 replaces schema.prisma with a contract, prisma generate with prisma contract emit, and @prisma/client with @prisma/orm-postgres and chained queries.&lt;/li&gt;
&lt;li&gt;$extends, increment, most nested writes, JSON filters, isolation levels and P2002 codes are missing, and only PostgreSQL is fully supported.&lt;/li&gt;
&lt;li&gt;orm init --from-prisma7-schema runs Prisma 7 and 8 side by side on one database; it needs --confirm app outside an interactive terminal.&lt;/li&gt;
&lt;li&gt;Check SHOW timezone before mixing writers: Prisma 8 leaves now() to the database, which skewed my timestamps by 5 hours.&lt;/li&gt;
&lt;li&gt;Prisma 7 is supported for 18 months after Prisma 8 GA, Prisma 6 security patches end November 19, 2026, and Accelerate retires December 1, 2026.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/prisma-8" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>prisma</category>
      <category>typescript</category>
      <category>postgres</category>
      <category>node</category>
    </item>
    <item>
      <title>TypeScript 7 Upgrade Guide: What Breaks, ESLint, Next.js and Real Benchmarks</title>
      <dc:creator>Waseem Anjum</dc:creator>
      <pubDate>Sun, 18 Oct 2026 18:51:06 +0000</pubDate>
      <link>https://dev.to/fabwaseem/typescript-7-upgrade-guide-what-breaks-eslint-nextjs-and-real-benchmarks-3m16</link>
      <guid>https://dev.to/fabwaseem/typescript-7-upgrade-guide-what-breaks-eslint-nextjs-and-real-benchmarks-3m16</guid>
      <description>&lt;p&gt;I tested the TypeScript 7 upgrade and ran into three catches that are easy to miss: removed tsconfig options, a &lt;code&gt;typescript-eslint&lt;/code&gt; break, and a side-by-side setup that changes which compiler &lt;code&gt;next build&lt;/code&gt; uses. Here is the full guide with the exact errors and fixes.&lt;/p&gt;

&lt;p&gt;TypeScript 7 is the native, Go-based rewrite of the TypeScript compiler, and it has been the &lt;code&gt;latest&lt;/code&gt; version on npm since July 8, 2026. It type-checks the same code as TypeScript 6 but much faster. On this box Hono's source built &lt;strong&gt;5.2x faster&lt;/strong&gt; (3.01 s to 0.58 s), with identical JavaScript output.&lt;/p&gt;

&lt;p&gt;The upgrade is usually one &lt;code&gt;npm install&lt;/code&gt;, with three real catches. Every option deprecated in TypeScript 6 is now a hard error. TypeScript 7 has no programmatic API, so &lt;code&gt;typescript-eslint&lt;/code&gt; refuses to run. And the side-by-side workaround quietly changes which compiler &lt;code&gt;next build&lt;/code&gt; uses. I hit all three while testing, so this guide gives the exact errors and fixes, cited to the &lt;a href="https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/" rel="noopener noreferrer"&gt;official TypeScript 7 announcement&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is TypeScript 7?
&lt;/h2&gt;

&lt;p&gt;TypeScript 7 is a faithful port of the TypeScript compiler and language service from TypeScript to Go. It ships as the normal &lt;code&gt;typescript&lt;/code&gt; npm package with the same &lt;code&gt;tsc&lt;/code&gt; command. Microsoft says it "typically" yields "speedups between 8x and 12x on full builds" through native code and shared-memory multithreading. The type system and emitted JavaScript match TypeScript 6.0.&lt;/p&gt;

&lt;p&gt;Key facts at a glance:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;TypeScript 6.0&lt;/th&gt;
&lt;th&gt;TypeScript 7.0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Written in&lt;/td&gt;
&lt;td&gt;TypeScript (runs on Node.js)&lt;/td&gt;
&lt;td&gt;Go (native binary, multithreaded)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;npm package&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;typescript@6&lt;/code&gt; / &lt;code&gt;@typescript/typescript6&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;typescript@7&lt;/code&gt; (&lt;code&gt;latest&lt;/code&gt;, 7.0.2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type checking&lt;/td&gt;
&lt;td&gt;Reference behavior&lt;/td&gt;
&lt;td&gt;Same semantics, with &lt;code&gt;stableTypeOrdering&lt;/code&gt; always on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deprecated options&lt;/td&gt;
&lt;td&gt;Error, silenced by &lt;code&gt;ignoreDeprecations: "6.0"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Hard error, cannot be silenced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Programmatic API&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;ts.createProgram&lt;/code&gt;, ...)&lt;/td&gt;
&lt;td&gt;None yet; planned for 7.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Editor&lt;/td&gt;
&lt;td&gt;Built-in TS server&lt;/td&gt;
&lt;td&gt;LSP server, VS Code extension&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The announcement adds that "practically any TypeScript code that compiles cleanly with TypeScript 6.0 (with the stableTypeOrdering flag on, and without any ignoreDeprecations flag set) should compile identically in TypeScript 7.0."&lt;/p&gt;

&lt;h2&gt;
  
  
  Is TypeScript 7 stable and ready for production?
&lt;/h2&gt;

&lt;p&gt;Yes, for compiling and type-checking. TypeScript 7.0 is a stable release, and the team tested it with companies including Bloomberg, Canva, Figma, Google, Linear, Notion, Sentry, Slack and Vercel. It is not ready for tools that need the compiler API, such as &lt;code&gt;typescript-eslint&lt;/code&gt; and Vue, Svelte, Astro or MDX tooling, until TypeScript 7.1 ships a new API.&lt;/p&gt;

&lt;p&gt;Quotable results from the announcement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Slack's CI type-checking went "from about 7.5 minutes to 1.25 minutes."&lt;/li&gt;
&lt;li&gt;The new language server "reduced failing language server commands by over 80%, and reduced server crashes by over 60%" compared with 6.0.&lt;/li&gt;
&lt;li&gt;Opening a file with an error in the VS Code codebase dropped from about 17.5 seconds to under 1.3 seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://github.com/microsoft/TypeScript/issues/63703" rel="noopener noreferrer"&gt;TypeScript 7.1 iteration plan&lt;/a&gt; lists "Stabilize API" as its first item. It scheduled the 7.1 beta for October 6 and the stable release for November 24, 2026. On October 17, 2026 npm still had no 7.1 beta, only nightly &lt;code&gt;7.1.0-dev&lt;/code&gt; builds under the &lt;code&gt;next&lt;/code&gt; tag, so treat the date as a target.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much faster is TypeScript 7? (my benchmark)
&lt;/h2&gt;

&lt;p&gt;On an 8-vCPU Linux box, TypeScript 7.0.2 built Hono's source (189 files, about 26,000 lines, declarations plus JavaScript) in a median 0.58 seconds, against 3.01 seconds for TypeScript 6.0.3. That is a 5.2x speedup with 27% less peak memory. Both compilers emitted byte-identical JavaScript for all 188 output files.&lt;/p&gt;

&lt;p&gt;The run: &lt;code&gt;tsc -p tsconfig.build.json&lt;/code&gt; on &lt;a href="https://github.com/honojs/hono" rel="noopener noreferrer"&gt;Hono&lt;/a&gt; at commit &lt;code&gt;5ca49b0&lt;/code&gt;, three runs each, Node.js 24.22.0, measured with &lt;code&gt;os.wait4&lt;/code&gt; for wall time and peak RSS.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Compiler&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;Runs&lt;/th&gt;
&lt;th&gt;Peak memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript 6.0.3&lt;/td&gt;
&lt;td&gt;3.01 s&lt;/td&gt;
&lt;td&gt;2.85, 3.01, 3.08&lt;/td&gt;
&lt;td&gt;528 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript 7.0.2 (default, 4 checkers)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.58 s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.58, 0.50, 0.59&lt;/td&gt;
&lt;td&gt;383 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript 7.0.2 &lt;code&gt;--checkers 8&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.61 s&lt;/td&gt;
&lt;td&gt;0.61, 0.60, 0.62&lt;/td&gt;
&lt;td&gt;416 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript 7.0.2 &lt;code&gt;--singleThreaded&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0.98 s&lt;/td&gt;
&lt;td&gt;0.98, 1.03, 0.89&lt;/td&gt;
&lt;td&gt;250 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhfb4d99qo2gvsq8qu2ad.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhfb4d99qo2gvsq8qu2ad.jpg" alt="Bar chart of the TypeScript 6 vs 7 benchmark on Hono: TypeScript 6.0.3 3.01 seconds, TypeScript 7.0.2 0.58 seconds, --checkers 8 0.61 seconds, --singleThreaded 0.98 seconds, with peak memory for each" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My 5.2x is below Microsoft's 8x to 12x because this project is small. Startup time weighs more on a 3-second build than on VS Code's 125-second one, so expect larger gains on larger codebases. Two things surprised me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;More checkers didn't help here.&lt;/strong&gt; &lt;code&gt;--checkers 8&lt;/code&gt; was no faster than the default of 4 and used more memory. The announcement says extra checkers help "larger codebases" at "the cost of increased memory usage."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Even single-threaded, TypeScript 7 was 3x faster&lt;/strong&gt; and used half the memory. On small CI runners &lt;code&gt;--singleThreaded&lt;/code&gt; or &lt;code&gt;--checkers 1&lt;/code&gt; is a reasonable choice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Does TypeScript 7 emit the same output?
&lt;/h3&gt;

&lt;p&gt;The JavaScript was identical. The &lt;code&gt;.d.ts&lt;/code&gt; files were not: 25 of 188 declaration files differed. In 16 the only change was quote style (&lt;code&gt;'/'&lt;/code&gt; instead of &lt;code&gt;"/"&lt;/code&gt;). In 9, TypeScript 7 wrote a reference such as &lt;code&gt;toArray: typeof toArray&lt;/code&gt; where TypeScript 6 had inlined the full function type:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-    toArray: (children: Child) =&amp;gt; Child[];
&lt;/span&gt;&lt;span class="gi"&gt;+    toArray: typeof toArray;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are equivalent types, but if you publish a library, diff your generated declarations or run your API report tool before releasing a version built with TypeScript 7.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks when upgrading to TypeScript 7?
&lt;/h2&gt;

&lt;p&gt;Three things break most often. Every compiler option deprecated in TypeScript 6.0 is now a hard error that &lt;code&gt;ignoreDeprecations&lt;/code&gt; no longer silences. The new defaults (&lt;code&gt;types: []&lt;/code&gt;, &lt;code&gt;rootDir: "./"&lt;/code&gt;, &lt;code&gt;strict: true&lt;/code&gt;) surface errors in existing configs. And any tool that imports the &lt;code&gt;typescript&lt;/code&gt; API fails, because 7.0 ships without one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Removed tsconfig options
&lt;/h3&gt;

&lt;p&gt;I ran a deliberately old &lt;code&gt;tsconfig.json&lt;/code&gt; through both compilers. TypeScript 6 already reports these as errors (&lt;code&gt;TS5101&lt;/code&gt;/&lt;code&gt;TS5107&lt;/code&gt;, "will stop functioning in TypeScript 7.0"), but you can silence them with &lt;code&gt;"ignoreDeprecations": "6.0"&lt;/code&gt;. TypeScript 7 reports &lt;code&gt;TS5102&lt;/code&gt;/&lt;code&gt;TS5108&lt;/code&gt; and ignores that flag (with it set, 7.0.2 still exited 1):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tsconfig.json(3,15): error TS5108: Option 'target=ES5' has been removed. Please remove it from your configuration.
tsconfig.json(5,25): error TS5108: Option 'moduleResolution=node10' has been removed. Please remove it from your configuration.
tsconfig.json(6,5): error TS5102: Option 'baseUrl' has been removed. Please remove it from your configuration.
  Use '"paths": {"*": ["./*"]}' instead.
tsconfig.json(7,24): error TS5090: Non-relative paths are not allowed. Did you forget a leading './'?
tsconfig.json(8,5): error TS5102: Option 'downlevelIteration' has been removed. Please remove it from your configuration.
tsconfig.json(9,24): error TS5108: Option 'esModuleInterop=false' has been removed. Please remove it from your configuration.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full list of removals and their replacements, from the announcement:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Removed in TypeScript 7&lt;/th&gt;
&lt;th&gt;Use instead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;target: "es5"&lt;/code&gt;, &lt;code&gt;downlevelIteration&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;es2015&lt;/code&gt; or later (a bundler can down-level if you must)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;moduleResolution: "node"&lt;/code&gt; / &lt;code&gt;"node10"&lt;/code&gt; / &lt;code&gt;"classic"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"nodenext"&lt;/code&gt; (Node) or &lt;code&gt;"bundler"&lt;/code&gt; (Vite, Next.js, webpack)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;module: "amd"&lt;/code&gt;, &lt;code&gt;"umd"&lt;/code&gt;, &lt;code&gt;"systemjs"&lt;/code&gt;, &lt;code&gt;"none"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"esnext"&lt;/code&gt; or &lt;code&gt;"preserve"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;baseUrl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;paths&lt;/code&gt; with &lt;code&gt;./&lt;/code&gt;-relative entries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;esModuleInterop: false&lt;/code&gt;, &lt;code&gt;allowSyntheticDefaultImports: false&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Remove (always on)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;alwaysStrict: false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Remove (always on)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;module Foo {}&lt;/code&gt; namespace syntax&lt;/td&gt;
&lt;td&gt;&lt;code&gt;namespace Foo {}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;import ... assert { type: "json" }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;import ... with { type: "json" }&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tsc file.ts&lt;/code&gt; inside a folder with &lt;code&gt;tsconfig.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tsc --ignoreConfig file.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  New defaults that cause errors
&lt;/h3&gt;

&lt;p&gt;After removing those options, two TypeScript 6 defaults (which 7 keeps) produced new errors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error TS5011: The common source directory of 'tsconfig.json' is './src'. The 'rootDir' setting must be
explicitly set to this or another path to adjust your output's file layout.

src/index.ts(2,19): error TS2591: Cannot find name 'process'. Do you need to install type definitions for node?
Try `npm i --save-dev @types/node` and then add 'node' to the types field in your tsconfig.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rootDir&lt;/code&gt; now defaults to &lt;code&gt;./&lt;/code&gt;, and &lt;code&gt;types&lt;/code&gt; now defaults to &lt;code&gt;[]&lt;/code&gt;, so &lt;code&gt;@types/*&lt;/code&gt; packages are no longer included automatically. This is the config that compiled and ran on TypeScript 7.0.2:&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;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"es2022"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nodenext"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"outDir"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dist"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rootDir"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./src"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"types"&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="s2"&gt;"node"&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;"include"&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="s2"&gt;"src"&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;Other defaults to know: &lt;code&gt;strict&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;, &lt;code&gt;module&lt;/code&gt; is &lt;code&gt;esnext&lt;/code&gt;, &lt;code&gt;noUncheckedSideEffectImports&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;, and &lt;code&gt;stableTypeOrdering&lt;/code&gt; is always on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Language and JavaScript changes
&lt;/h3&gt;

&lt;p&gt;Template literal inference now keeps Unicode code points whole. This compiles on TypeScript 7 and fails on 6, which also makes it a quick way to prove which compiler a tool is running:&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/which-ts.ts — compiles only on TypeScript 7&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;HeadTail&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;S&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;S&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt; &lt;span class="nx"&gt;Head&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt; &lt;span class="nx"&gt;Tail&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;Head&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Tail&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;never&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;check&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HeadTail&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;😀abc&lt;/span&gt;&lt;span class="dl"&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="s1"&gt;😀&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;abc&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;TypeScript 6 reports &lt;code&gt;Type '"😀"' is not assignable to type '"�"'&lt;/code&gt;, because it splits the emoji into two UTF-16 halves.&lt;/p&gt;

&lt;p&gt;JSDoc-typed JavaScript is also stricter: &lt;code&gt;@enum&lt;/code&gt;, &lt;code&gt;@class&lt;/code&gt;, a bare &lt;code&gt;?&lt;/code&gt; type and Closure-style &lt;code&gt;function(string): void&lt;/code&gt; are no longer recognized. The &lt;a href="https://github.com/microsoft/typescript-go/blob/main/CHANGES.md" rel="noopener noreferrer"&gt;CHANGES.md&lt;/a&gt; file tracks the full list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does typescript-eslint work with TypeScript 7?
&lt;/h2&gt;

&lt;p&gt;No. &lt;code&gt;typescript-eslint&lt;/code&gt; 8.71.2 (the latest on October 17, 2026) declares &lt;code&gt;typescript &amp;gt;=4.8.4 &amp;lt;6.1.0&lt;/code&gt; as a peer dependency and throws at startup when it finds TypeScript 7. Because &lt;code&gt;eslint-config-next&lt;/code&gt; uses it, &lt;code&gt;npm run lint&lt;/code&gt; fails in every Next.js app that upgrades. The fix is to keep TypeScript 6 installed under the &lt;code&gt;typescript&lt;/code&gt; name for the API.&lt;/p&gt;

&lt;p&gt;This is the exact error from &lt;code&gt;npx eslint .&lt;/code&gt; in a Next.js 16.4.2 app after &lt;code&gt;npm i -D typescript@7.0.2&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;typescript-eslint does not support TS 7.0.
Please see https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/#running-side-by-side-with-typescript-6.0 to run typescript-eslint using the TS 6 API.
See also https://github.com/typescript-eslint/typescript-eslint/issues/10940 for tracking typescript-eslint's support for TS &amp;gt;=7.1

Oops! Something went wrong! :(
ESLint: 9.39.5
Error: typescript-eslint does not support TS 7.0.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Support is tracked in &lt;a href="https://github.com/typescript-eslint/typescript-eslint/issues/10940" rel="noopener noreferrer"&gt;typescript-eslint issue #10940&lt;/a&gt; and depends on the 7.1 API.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I run TypeScript 7 and 6 side by side?
&lt;/h2&gt;

&lt;p&gt;Install TypeScript 7 under an alias such as &lt;code&gt;@typescript/native&lt;/code&gt;, and install &lt;code&gt;@typescript/typescript6&lt;/code&gt; under the &lt;code&gt;typescript&lt;/code&gt; name. Tools that import &lt;code&gt;typescript&lt;/code&gt; (ESLint, Vue, Svelte) get the 6.0 API, while &lt;code&gt;npx tsc&lt;/code&gt; runs the TypeScript 7 binary. This is the setup the TypeScript team recommends in its announcement.&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;"devDependencies"&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;"@typescript/native"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm:typescript@^7.0.2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"typescript"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npm:@typescript/typescript6@^6.0.2"&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;What I saw after &lt;code&gt;npm install&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;$ npx tsc -v
Version 7.0.2
$ npx tsc6 -v
Version 6.0.3
$ npx eslint .
(no output, exit 0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The compatibility package ships a &lt;code&gt;tsc6&lt;/code&gt; binary, so the two never fight over the name &lt;code&gt;tsc&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The catch: next build now uses TypeScript 6
&lt;/h3&gt;

&lt;p&gt;With the alias setup, &lt;code&gt;next build&lt;/code&gt; type-checks with &lt;strong&gt;TypeScript 6&lt;/strong&gt;, not 7. Next.js resolves the package named &lt;code&gt;typescript&lt;/code&gt;, finds the compatibility package and runs its &lt;code&gt;tsc6&lt;/code&gt;. I proved it with the emoji file above: &lt;code&gt;npx tsc&lt;/code&gt; passed, but &lt;code&gt;next build&lt;/code&gt; failed with the TypeScript 6 error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Running TypeScript ...
lib/which-ts.ts(3,42): error TS2322: Type '"😀"' is not assignable to type '"�"'.
Failed to type check.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So pick one of two setups until typescript-eslint supports 7.1:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;next build&lt;/code&gt; checks with&lt;/th&gt;
&lt;th&gt;ESLint (type-aware)&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;A.&lt;/strong&gt; &lt;code&gt;typescript@7&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;TypeScript 7 (fast)&lt;/td&gt;
&lt;td&gt;Broken&lt;/td&gt;
&lt;td&gt;Projects that lint with Biome or oxlint, or no type-aware lint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;B.&lt;/strong&gt; Alias: &lt;code&gt;typescript&lt;/code&gt; = TS 6, &lt;code&gt;@typescript/native&lt;/code&gt; = TS 7&lt;/td&gt;
&lt;td&gt;TypeScript 6&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;td&gt;Projects on &lt;code&gt;eslint-config-next&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;With setup B, add &lt;code&gt;npx tsc --noEmit&lt;/code&gt; as its own CI step to get TypeScript 7's speed for type checking. Next.js's &lt;code&gt;typescript.ignoreBuildErrors&lt;/code&gt; would skip the slower check in &lt;code&gt;next build&lt;/code&gt;, but only use it if that &lt;code&gt;tsc&lt;/code&gt; step blocks the merge, because the &lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/typescript" rel="noopener noreferrer"&gt;Next.js docs&lt;/a&gt; call that option "very dangerous" on its own.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp2brytzywz7nzm933sgw.jpg" 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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp2brytzywz7nzm933sgw.jpg" alt="Decision flow for upgrading to TypeScript 7: fix TypeScript 6 deprecations first, then if the project uses typescript-eslint, Vue, Svelte, Astro or MDX tooling, use the alias setup with tsc 7 in CI, otherwise install typescript@7 directly" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Does Next.js support TypeScript 7?
&lt;/h2&gt;

&lt;p&gt;Yes. Since Next.js 16.3, &lt;code&gt;next build&lt;/code&gt; can type-check with TypeScript 7, and in 16.4 it uses the project's &lt;code&gt;tsc&lt;/code&gt; CLI by default (&lt;code&gt;experimental.useTypeScriptCli: true&lt;/code&gt;), which works without the compiler API. Install &lt;code&gt;typescript@^7&lt;/code&gt; and it is used automatically. In my test the "Running TypeScript" step dropped from 1,368 ms to 231 ms.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://nextjs.org/blog/next-16-3" rel="noopener noreferrer"&gt;Next.js 16.3 release post&lt;/a&gt; says: "To start using TypeScript 7 for type checking during next build, just bump your project's local dependency." The &lt;a href="https://nextjs.org/docs/app/api-reference/config/typescript" rel="noopener noreferrer"&gt;TypeScript config docs&lt;/a&gt; add the trade-off: CLI type checking "prints the native tsc diagnostics" and "does not apply Next.js-specific code frames," and it checks every file your &lt;code&gt;tsconfig.json&lt;/code&gt; includes, test files too.&lt;/p&gt;

&lt;p&gt;My measurements on the Next.js 16.4.2 app from my &lt;a href="https://www.fabwaseem.com/blog/nextjs-use-cache" rel="noopener noreferrer"&gt;use cache guide&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;TypeScript 5.9.3 (create-next-app default)&lt;/th&gt;
&lt;th&gt;TypeScript 7.0.2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;npx tsc --noEmit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1.37 s&lt;/td&gt;
&lt;td&gt;0.38 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;next build&lt;/code&gt; "Finished TypeScript in"&lt;/td&gt;
&lt;td&gt;1,368 ms&lt;/td&gt;
&lt;td&gt;231 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real type error caught by &lt;code&gt;next build&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (&lt;code&gt;TS2322&lt;/code&gt;, build fails)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;create-next-app&lt;/code&gt; 16.4.2 still writes &lt;code&gt;"typescript": "^5"&lt;/code&gt; into new projects, so new apps start on 5.9 until you upgrade. The app's default tsconfig (&lt;code&gt;moduleResolution: "bundler"&lt;/code&gt;, no &lt;code&gt;baseUrl&lt;/code&gt;) needed no changes for TypeScript 7.&lt;/p&gt;

&lt;h2&gt;
  
  
  TypeScript 7 upgrade checklist
&lt;/h2&gt;

&lt;p&gt;To upgrade safely, get to TypeScript 6.0 with zero deprecation errors first, then install TypeScript 7, fix the &lt;code&gt;types&lt;/code&gt; and &lt;code&gt;rootDir&lt;/code&gt; defaults, decide how to keep the 6.0 API for ESLint and framework tooling, and add TypeScript 7 to CI. Library authors should also diff their generated &lt;code&gt;.d.ts&lt;/code&gt; files before publishing.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade to TypeScript 6.0&lt;/strong&gt; and remove &lt;code&gt;ignoreDeprecations&lt;/code&gt;. Fix every &lt;code&gt;TS5101&lt;/code&gt;/&lt;code&gt;TS5107&lt;/code&gt; deprecation error; each becomes a hard TypeScript 7 error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace &lt;code&gt;baseUrl&lt;/code&gt;&lt;/strong&gt; with &lt;code&gt;./&lt;/code&gt;-relative &lt;code&gt;paths&lt;/code&gt;, and &lt;code&gt;moduleResolution: "node"&lt;/code&gt; with &lt;code&gt;"bundler"&lt;/code&gt; or &lt;code&gt;"nodenext"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Install TypeScript 7&lt;/strong&gt;: &lt;code&gt;npm i -D typescript@^7&lt;/code&gt; (or the alias setup if step 6 applies).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;"types": ["node"]&lt;/code&gt;&lt;/strong&gt; (plus &lt;code&gt;jest&lt;/code&gt;, &lt;code&gt;bun&lt;/code&gt; and so on) if globals like &lt;code&gt;process&lt;/code&gt; stop resolving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;rootDir&lt;/code&gt;&lt;/strong&gt; when &lt;code&gt;tsconfig.json&lt;/code&gt; sits above &lt;code&gt;src&lt;/code&gt; and you emit files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check API-dependent tools&lt;/strong&gt;: typescript-eslint, &lt;code&gt;eslint-config-next&lt;/code&gt;, Vue/Volar, Svelte, Astro, MDX, Angular templates. If you use any, keep &lt;code&gt;typescript&lt;/code&gt; pointed at &lt;code&gt;@typescript/typescript6&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add &lt;code&gt;npx tsc --noEmit&lt;/code&gt; to CI&lt;/strong&gt; and check that it runs version 7 (&lt;code&gt;npx tsc -v&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tune parallelism&lt;/strong&gt;: try &lt;code&gt;--checkers 1&lt;/code&gt; or &lt;code&gt;--singleThreaded&lt;/code&gt; on small CI runners and higher &lt;code&gt;--checkers&lt;/code&gt; on large monorepos, and measure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Libraries: diff &lt;code&gt;.d.ts&lt;/code&gt; output&lt;/strong&gt; between 6 and 7 before publishing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editor&lt;/strong&gt;: install the TypeScript 7 VS Code extension, and use "Disable TypeScript 7 Language Server" for Vue or Svelte projects.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Common TypeScript 7 upgrade mistakes
&lt;/h2&gt;

&lt;p&gt;The most common mistakes are upgrading straight from 5.x without passing through 6.0's deprecation errors, assuming the alias setup makes &lt;code&gt;next build&lt;/code&gt; use TypeScript 7, forgetting the new &lt;code&gt;types: []&lt;/code&gt; default, and trusting a passing build while lint is silently skipped. Each produces either a confusing error or a slower build than you expected.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mistake&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Skipping TypeScript 6&lt;/td&gt;
&lt;td&gt;Wall of &lt;code&gt;TS5102&lt;/code&gt;/&lt;code&gt;TS5108&lt;/code&gt; errors&lt;/td&gt;
&lt;td&gt;Upgrade to 6.0 first and clear its deprecations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keeping &lt;code&gt;ignoreDeprecations: "6.0"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Still errors on 7&lt;/td&gt;
&lt;td&gt;Remove it and fix the options&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;typescript@7&lt;/code&gt; with &lt;code&gt;eslint-config-next&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;"typescript-eslint does not support TS 7.0"&lt;/td&gt;
&lt;td&gt;Alias setup B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alias setup but expecting fast &lt;code&gt;next build&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Build still uses &lt;code&gt;tsc6&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Run &lt;code&gt;npx tsc --noEmit&lt;/code&gt; in CI as well&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relying on automatic &lt;code&gt;@types/*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Cannot find name 'process'&lt;/code&gt; (TS2591)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"types": ["node"]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upgrading a Vue, Svelte or Astro app's editor&lt;/td&gt;
&lt;td&gt;Lost template type checking&lt;/td&gt;
&lt;td&gt;Keep TypeScript 6 for the editor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publishing a library without checking &lt;code&gt;.d.ts&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Different declaration text&lt;/td&gt;
&lt;td&gt;Diff and review before release&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you deploy through Docker, rebuild without cache after changing the compiler. My &lt;a href="https://www.fabwaseem.com/blog/nextjs-dockerfile" rel="noopener noreferrer"&gt;Next.js Dockerfile guide&lt;/a&gt; shows a multi-stage build where &lt;code&gt;npm run build&lt;/code&gt;, and with it the type check, runs in the builder stage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; Prisma 8 needs TypeScript 5.9 or newer, and its client type-checked cleanly on TypeScript 7.0.2 in my test. See &lt;a href="https://www.fabwaseem.com/blog/prisma-8" rel="noopener noreferrer"&gt;Prisma 8 vs Prisma 7: should you upgrade?&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; on NestJS, TypeScript 7 isn't usable through the Nest CLI yet: &lt;code&gt;nest build&lt;/code&gt; asks for TypeScript 6 until 7.1. Plain &lt;code&gt;tsc&lt;/code&gt; 7 works. Details in my &lt;a href="https://www.fabwaseem.com/blog/nestjs-12" rel="noopener noreferrer"&gt;NestJS 12 migration guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related:&lt;/strong&gt; Oxlint's type-aware mode runs on typescript-go too. On this site's code it matched all 80 typescript-eslint findings in 1.29 s vs 20.5 s; details in &lt;a href="https://www.fabwaseem.com/blog/oxlint-vs-eslint" rel="noopener noreferrer"&gt;Oxlint vs Biome vs ESLint&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;TypeScript 7 is the native Go port of TypeScript, &lt;code&gt;latest&lt;/code&gt; on npm since July 8, 2026 (7.0.2), with the same type system as 6.0.&lt;/li&gt;
&lt;li&gt;On Hono's source it built 5.2x faster than TypeScript 6 on my 8-vCPU box, with identical JavaScript output.&lt;/li&gt;
&lt;li&gt;Everything deprecated in TypeScript 6 is a hard error in 7: es5, node10 resolution, baseUrl, AMD/UMD modules, esModuleInterop false.&lt;/li&gt;
&lt;li&gt;New defaults bite: types is [] and rootDir is ./, so add "types": ["node"] and an explicit rootDir.&lt;/li&gt;
&lt;li&gt;typescript-eslint does not support TypeScript 7; keep @typescript/typescript6 under the typescript name until 7.1 ships an API.&lt;/li&gt;
&lt;li&gt;Next.js 16.3+ type-checks with TypeScript 7 automatically, but the alias setup makes next build use TypeScript 6, so run npx tsc in CI too.&lt;/li&gt;
&lt;li&gt;Library authors should diff .d.ts output, which can differ in quote style and typeof references.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.fabwaseem.com/blog/typescript-7" rel="noopener noreferrer"&gt;fabwaseem.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>javascript</category>
      <category>nextjs</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
