<?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: Muhammad Zeeshan Sardar</title>
    <description>The latest articles on DEV Community by Muhammad Zeeshan Sardar (@zeeshansardar08).</description>
    <link>https://dev.to/zeeshansardar08</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%2F3815588%2F642e7ea5-a321-4d5b-abf8-d4a1c64a51b7.png</url>
      <title>DEV Community: Muhammad Zeeshan Sardar</title>
      <link>https://dev.to/zeeshansardar08</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zeeshansardar08"/>
    <language>en</language>
    <item>
      <title>React 19.3 View Transitions: The Suspense Patterns That Keep an App Fast</title>
      <dc:creator>Muhammad Zeeshan Sardar</dc:creator>
      <pubDate>Thu, 12 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/zeeshansardar08/react-193-view-transitions-the-suspense-patterns-that-keep-an-app-fast-2fa9</link>
      <guid>https://dev.to/zeeshansardar08/react-193-view-transitions-the-suspense-patterns-that-keep-an-app-fast-2fa9</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React 19.3 (9 September 2026) made &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; stable, and Next.js 16.4 ships it. In the App Router every navigation is a Transition, so boundaries animate with no extra code.&lt;/li&gt;
&lt;li&gt;An unconfigured boundary animates on every trigger. Start from &lt;code&gt;default="none"&lt;/code&gt; and opt into the triggers you want.&lt;/li&gt;
&lt;li&gt;Follow the Suspense rule: fallbacks appear instantly, the reveal animates, cached content never animates. &lt;code&gt;update="auto" default="none"&lt;/code&gt; around &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt; does all three.&lt;/li&gt;
&lt;li&gt;A DOM node above a boundary, a boundary in a layout, or a shared pair without &lt;code&gt;share&lt;/code&gt; all disable animations silently.&lt;/li&gt;
&lt;li&gt;Reduced motion and clicks during animations are your responsibility, not React's.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of the View Transition bugs that ship in React apps over the next year will come from animating too much, not too little. React 19.3 made &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; stable, Next.js 16.4 bundles it, and the default behaviour is generous: an unconfigured boundary cross-fades on every Transition that touches it. That is pleasant in a demo and tiring in a product. The teams that get this right will treat animation as something each boundary opts into, starting from &lt;code&gt;default="none"&lt;/code&gt;, and will follow one rule for loading states that the React team spelled out in the 19.3 announcement.&lt;/p&gt;

&lt;p&gt;This guide takes that position and builds on it. It covers what became stable and when, which updates can trigger an animation at all, the Suspense rule, shared elements and directional navigation in Next.js, the placement mistakes that switch animations off without an error, and the accessibility and browser-support work that remains your responsibility. It ends with the checks we run on a codebase before View Transitions go to production.&lt;/p&gt;

&lt;p&gt;Every API detail below was checked on 9 November 2026 against the React 19.3 release post and reference docs, the Next.js 16.4 release post and view transitions guide, the documentation bundled inside the &lt;code&gt;next@16.4.5&lt;/code&gt; package, and the npm registry. Where two official sources disagree, the section says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  What React 19.3 made stable, and which versions you need
&lt;/h2&gt;

&lt;p&gt;React 19.3.0 was published to npm on 9 September 2026, and the &lt;a href="https://react.dev/blog/2026/09/09/react-19-3" rel="noopener noreferrer"&gt;React 19.3 announcement&lt;/a&gt; moved two APIs out of the experimental channel: &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; and Fragment refs. Both had been previewed as experimental the year before. The current patch at the time of writing is 19.3.1, published on 14 October 2026, and it is what the &lt;code&gt;latest&lt;/code&gt; tag on npm resolves to.&lt;/p&gt;

&lt;p&gt;The release also added &lt;code&gt;addTransitionType&lt;/code&gt; support for typed animations, a &lt;code&gt;browser()&lt;/code&gt; API in &lt;code&gt;react-dom&lt;/code&gt; for opting components out of server rendering, Trusted Types support, and the ability for Server Components to render a Context directly. Nothing in the release is listed as a breaking change, which is consistent with it shipping as a minor version rather than React 20.&lt;/p&gt;

&lt;p&gt;For Next.js projects, the version that matters is 16.4. The &lt;a href="https://nextjs.org/blog/next-16-4" rel="noopener noreferrer"&gt;Next.js 16.4 release post&lt;/a&gt;, published on 6 October 2026, states that it ships with React 19.3, including stable View Transitions and Fragment refs. If you are upgrading for this reason, go straight to the current patch rather than 16.4.0. On 3 November the team published a &lt;a href="https://nextjs.org/blog/november-2026-security-release" rel="noopener noreferrer"&gt;security release&lt;/a&gt; as 16.4.5 and 15.5.29, fixing one high and several medium severity issues, most of them in Cache Components. If your project is still on Next.js 15, our &lt;a href="https://zignites.com/blog/nextjs-16-upgrade-checklist" rel="noopener noreferrer"&gt;Next.js 16 upgrade checklist&lt;/a&gt; covers the breaking changes to handle first.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Version checked&lt;/th&gt;
&lt;th&gt;Published&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;react&lt;/code&gt;, &lt;code&gt;react-dom&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;19.3.1&lt;/td&gt;
&lt;td&gt;14 October 2026&lt;/td&gt;
&lt;td&gt;Stable &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt;, Fragment refs, &lt;code&gt;addTransitionType&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;next&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16.4.5&lt;/td&gt;
&lt;td&gt;3 November 2026&lt;/td&gt;
&lt;td&gt;Ships React 19.3, &lt;code&gt;transitionTypes&lt;/code&gt; on &lt;code&gt;&amp;lt;Link&amp;gt;&lt;/code&gt;, security fixes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keep &lt;code&gt;react&lt;/code&gt; and &lt;code&gt;react-dom&lt;/code&gt; on the same version. A mismatch is one of the quicker ways to lose an afternoon, because npm refuses the install and forcing it produces runtime errors that look unrelated to the upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which updates can trigger a View Transition
&lt;/h2&gt;

&lt;p&gt;A View Transition only runs when an update is marked as non-urgent. The &lt;a href="https://react.dev/reference/react/ViewTransition" rel="noopener noreferrer"&gt;&lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; reference&lt;/a&gt; names three sources: state 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 an update coming from &lt;code&gt;useDeferredValue&lt;/code&gt;. A plain &lt;code&gt;setState&lt;/code&gt; in an event handler never animates, however the boundary is configured.&lt;/p&gt;

&lt;p&gt;That design is deliberate, and it is worth understanding before writing any CSS. Urgent updates, such as typing into an input or toggling a checkbox, should appear immediately, and React will not put an animation between the user and the result. Non-urgent updates are the ones where React is already willing to wait, so adding a short animation does not change how responsive the interface feels.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="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;useState&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="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="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Panel&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;open&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setOpen&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="kc"&gt;false&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;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="nf"&gt;setOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&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;o&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;
        Toggle details
      &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;open&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="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;Details&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;)&lt;/span&gt;&lt;span class="si"&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;Remove &lt;code&gt;startTransition&lt;/code&gt; from that click handler and the panel still opens, but without animation. When someone reports that "the animation only works sometimes", this is the first thing to check: one code path wraps the update in a Transition and another does not.&lt;/p&gt;

&lt;p&gt;In the Next.js App Router, route navigations are already Transitions, so &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; boundaries animate during navigation without any extra code. The &lt;a href="https://nextjs.org/docs/app/guides/view-transitions" rel="noopener noreferrer"&gt;Next.js view transitions guide&lt;/a&gt; makes this point directly, and it is the reason a single unconfigured boundary in a shared component can start animating on every page change.&lt;/p&gt;

&lt;p&gt;You also never call &lt;code&gt;document.startViewTransition()&lt;/code&gt; yourself. React calls it for you, and calling it alongside React's own handling produces competing transitions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four triggers: enter, exit, update and share
&lt;/h2&gt;

&lt;p&gt;React picks an animation based on how the tree changed around each boundary. There are four triggers, and each one can be configured separately:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;enter&lt;/strong&gt; runs when the &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; is inserted during a Transition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;exit&lt;/strong&gt; runs when it is removed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;update&lt;/strong&gt; runs when DOM inside it changes, or when its size or position changes because of an immediate sibling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;share&lt;/strong&gt; runs when a named boundary is removed in one place and a boundary with the same name is inserted somewhere else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each trigger prop accepts &lt;code&gt;"auto"&lt;/code&gt; (the browser's default cross-fade), &lt;code&gt;"none"&lt;/code&gt;, a class name of your own, or an object keyed by transition type. A class name is the usual choice, because it connects the boundary to CSS through the view transition pseudo-elements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="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;exit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"fade-out"&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;Notice&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&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;.slide-up&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="m"&gt;220ms&lt;/span&gt; &lt;span class="nb"&gt;ease-out&lt;/span&gt; &lt;span class="nb"&gt;both&lt;/span&gt; &lt;span class="n"&gt;slide-up&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;.fade-out&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="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-out&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-up&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="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;8px&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="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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;@keyframes&lt;/span&gt; &lt;span class="n"&gt;fade-out&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The React docs recommend styling through these classes rather than targeting &lt;code&gt;view-transition-name&lt;/code&gt; values directly. Classes survive refactors and work with React's generated names; hard-coded names do not.&lt;/p&gt;

&lt;p&gt;There is also an imperative route. The event props &lt;code&gt;onEnter&lt;/code&gt;, &lt;code&gt;onExit&lt;/code&gt;, &lt;code&gt;onUpdate&lt;/code&gt; and &lt;code&gt;onShare&lt;/code&gt; receive the transition instance and the active types, so you can drive the animation with the Web Animations API and return a cleanup function. Only one event fires per boundary per Transition, and &lt;code&gt;onShare&lt;/code&gt; wins over &lt;code&gt;onEnter&lt;/code&gt; and &lt;code&gt;onExit&lt;/code&gt;. Most interfaces never need this, and CSS keeps the animation where designers can find it.&lt;/p&gt;

&lt;p&gt;One more detail matters for layouts: React treats each boundary's content as a single image that moves, scales and cross-fades. Child elements inside it do not animate their own positions. If a card's title and image should move independently, they need separate boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why default="none" should be your starting point
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;default="none"&lt;/code&gt; turns off every trigger on a boundary except the ones you list explicitly. It is the single most useful prop on &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt;, and starting from it is the opt-in model this guide argues for.&lt;/p&gt;

&lt;p&gt;The problem it solves is not obvious until an app has more than a few boundaries. An unconfigured boundary uses &lt;code&gt;"auto"&lt;/code&gt; for every trigger, so it cross-fades whenever any Transition updates it. In the App Router that means every navigation, every &lt;code&gt;router.refresh()&lt;/code&gt;, and every Suspense reveal nearby. The Next.js guide puts it plainly: without &lt;code&gt;default="none"&lt;/code&gt;, every named &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; animates whenever any transition runs on the page. Individually, each cross-fade is subtle. Together, they make an interface feel slow even when the data arrives quickly, because the user watches content dissolve rather than appear.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ViewTransition&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="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="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SearchResults&lt;/span&gt; &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;query&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That boundary animates when the results first enter and stays still for everything else. If you later decide that updates should cross-fade too, you add &lt;code&gt;update="auto"&lt;/code&gt;, and the change is visible in code review instead of being an accident of defaults.&lt;/p&gt;

&lt;p&gt;There is one trap. A named pair used for a shared element transition needs an explicit &lt;code&gt;share&lt;/code&gt; prop once you add &lt;code&gt;default="none"&lt;/code&gt;. The Next.js guide warns that with &lt;code&gt;default="none"&lt;/code&gt; and no &lt;code&gt;share&lt;/code&gt;, the pair silently stops morphing. Nothing errors, and the morph simply disappears.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="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;`product-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="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="nc"&gt;ProductImage&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="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;As a working rule, we treat any &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; without &lt;code&gt;default="none"&lt;/code&gt; as something a reviewer should question. Sometimes the generous default is right, for a small, self-contained widget that only ever changes through one Transition. Usually it is a boundary that nobody has fully thought through yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Suspense rule: instant fallbacks, animated reveals
&lt;/h2&gt;

&lt;p&gt;The React 19.3 announcement gives three principles for animating around Suspense, and they are the core of this guide:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fallbacks should appear immediately, without animation.&lt;/li&gt;
&lt;li&gt;A fallback should change to its final content with animation.&lt;/li&gt;
&lt;li&gt;Children that do not suspend should appear immediately, without animation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each principle protects responsiveness in a different way. An instant fallback tells the user that their click registered; fading it in delays that acknowledgement for no benefit. An animated reveal smooths the jump from skeleton to content, which is the moment where a hard cut feels most jarring. And content that is already cached should never animate, because an animation on data the user has seen before turns an instant response into a slow one.&lt;/p&gt;

&lt;p&gt;The default configuration breaks the first and third principles. Wrap a Suspense boundary in a plain &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; and the fallback fades in, then content that was already loaded animates again every time the component is shown. The announcement's fix is to switch off everything except updates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ViewTransition&lt;/span&gt; &lt;span class="na"&gt;update&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"auto"&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;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;OrderSummarySkeleton&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;OrderSummary&lt;/span&gt; &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;orderId&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;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="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;Now the skeleton appears at once, the change from skeleton to summary cross-fades, and once the summary is cached, toggling it is instant. This is a small change with a large effect on how an app feels, and it is the pattern we would put in a team's shared components before anything more decorative.&lt;/p&gt;

&lt;p&gt;Suspense boundaries also wait for more than data. Inside a &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt;, React waits up to 500 milliseconds for fonts and visible images to load before running the animation, and the 19.3 post shows how wrapping images and stylesheets inside a boundary lets them take part in Suspense. That turns the familiar flicker of an image arriving late into a coordinated reveal, as long as the image loads within that window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing between the update and enter/exit patterns for Suspense
&lt;/h2&gt;

&lt;p&gt;There are two ways to animate a Suspense reveal, and they communicate different things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The update pattern&lt;/strong&gt; wraps the whole Suspense boundary in one &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt;. The fallback and the content share one identity, so React cross-fades from one to the other in place. It suits content that occupies the same space as its skeleton, such as a card, a table or a summary panel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The enter and exit pattern&lt;/strong&gt; puts one boundary around the fallback and another around the content. The fallback exits and the content enters, each with its own animation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="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="nc"&gt;ArticleSkeleton&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;Article&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;slug&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;This is the pattern the Next.js guide uses for a detail page, with deliberately uneven timing: the skeleton leaves in about 150 milliseconds, and the content's fade waits for that exit to finish before arriving over roughly 210 milliseconds, while its slide runs for 400. The reasoning is sound. Old content should leave quickly so that it does not compete for attention, and new content should arrive gently enough for the eye to register it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Pattern&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;Skeleton and content occupy the same box&lt;/td&gt;
&lt;td&gt;Update, one boundary around &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Reads as the same thing becoming ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content is a different shape from its skeleton&lt;/td&gt;
&lt;td&gt;Enter and exit, one boundary each&lt;/td&gt;
&lt;td&gt;Avoids stretching one image into another&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content is usually cached&lt;/td&gt;
&lt;td&gt;Either, with &lt;code&gt;default="none"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Cached reveals should not animate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many small boundaries on one screen&lt;/td&gt;
&lt;td&gt;Update, or none at all&lt;/td&gt;
&lt;td&gt;Several entrances at once looks busy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you are unsure, start with the update pattern and &lt;code&gt;default="none"&lt;/code&gt;. It is the quieter of the two and the harder one to get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared element morphs and the prefetch condition
&lt;/h2&gt;

&lt;p&gt;A shared element transition makes one element appear to move between two states: a product thumbnail growing into the hero image on its detail page, for example. You create one by giving two boundaries the same &lt;code&gt;name&lt;/code&gt;, one in the old tree and one in the new:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In the grid&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`/products/&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="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="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;`product-&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="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="nc"&gt;Image&lt;/span&gt; &lt;span class="na"&gt;src&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;thumb&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;alt&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;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;320&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;320&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;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// On the detail page&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;`product-&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="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="nc"&gt;Image&lt;/span&gt; &lt;span class="na"&gt;src&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;hero&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;alt&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;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;960&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;960&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three conditions decide whether the morph actually plays, and each one fails silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Names must be unique across the whole app at any moment.&lt;/strong&gt; Only one boundary with a given name can be mounted at a time. A list where every item uses the same name raises an error about two components sharing a name, which is the easy case. The harder case is a name that collides only when two specific components happen to be mounted together. Build names from a stable identifier, and keep any fixed names in a module of namespaced constants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both sides must be in the viewport.&lt;/strong&gt; If either element is off screen, React does not form a pair, and each side runs an ordinary enter or exit animation instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The destination must render in the same commit.&lt;/strong&gt; This is the condition that catches Next.js teams. The guide explains that the morph plays when the destination content renders in the same commit as the navigation, which is the case for prefetched pages. If the destination suspends into a fallback first, there is no pair, and the content uses its enter animation when it arrives. In practice, a shared element morph is only as reliable as your prefetching. If the detail page fetches uncached data before it can render the hero image, the morph will rarely play, and the fix belongs in your data and caching strategy, not in CSS.&lt;/p&gt;

&lt;p&gt;The Next.js guide also adds a short blur to the image pair during the morph. It is a useful trick, because it hides the scaling artefacts that appear when a small image is stretched to a larger size mid-flight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Directional navigation with transition types in Next.js 16.4
&lt;/h2&gt;

&lt;p&gt;Transition types let one boundary choose different animations depending on why a Transition happened. In plain React you add a type inside the Transition with &lt;a href="https://react.dev/reference/react/addTransitionType" rel="noopener noreferrer"&gt;&lt;code&gt;addTransitionType&lt;/code&gt;&lt;/a&gt;, for example &lt;code&gt;addTransitionType('next')&lt;/code&gt;. In Next.js, &lt;code&gt;&amp;lt;Link&amp;gt;&lt;/code&gt; accepts a &lt;code&gt;transitionTypes&lt;/code&gt; prop, and the bundled documentation for 16.4.5 records it as added in version 16.2.0. &lt;code&gt;useRouter&lt;/code&gt; supports the same option on &lt;code&gt;push()&lt;/code&gt; and &lt;code&gt;replace()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`/orders/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&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="s1"&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;
  View order
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="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;"/orders"&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="s1"&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;
  Back to orders
&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;Each page then maps those types to animations, with everything else switched off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="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="s1"&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="s1"&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="s1"&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="s1"&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="s1"&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="s1"&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="s1"&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="s1"&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="s1"&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="s1"&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="nc"&gt;OrdersTable&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;Two details from the Next.js guide decide whether this works. First, put the wrapper in each &lt;code&gt;page.tsx&lt;/code&gt;, not in a layout. Layouts persist across navigations, so their boundaries never enter or exit and the directional animation never fires. Second, decide which links count as forward and which as back based on your navigation hierarchy, because Next.js does not infer it. The guide's convention, with forward content sliding in from the right and back content from the left, matches how readers of left-to-right languages expect pages to turn.&lt;/p&gt;

&lt;p&gt;Directional slides also need a fixed reference point. If the header slides with the content, the user loses track of what moved. The guide gives the header its own &lt;code&gt;viewTransitionName&lt;/code&gt; and disables its animation, so the header stays put while the page content moves beneath it. And it adds &lt;code&gt;pointer-events: none&lt;/code&gt; to the &lt;code&gt;::view-transition&lt;/code&gt; overlay, because without it, clicks made during an animation are swallowed by the overlay instead of reaching the page.&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&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one rule is easy to miss and fixes a whole class of "the button didn't respond" reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Next.js configuration detail where the docs disagree
&lt;/h2&gt;

&lt;p&gt;Whether Next.js needs a configuration flag for View Transitions depends on which page of its documentation you read. The hosted reference page for &lt;code&gt;experimental.viewTransition&lt;/code&gt;, still labelled for version 16.2.3, says to enable the flag in &lt;code&gt;next.config.js&lt;/code&gt; and warns that the feature is experimental and not recommended for production. The view transitions guide, last updated on 15 October 2026, says that view transitions work in the App Router with no configuration and that React 19.3 provides everything it uses.&lt;/p&gt;

&lt;p&gt;The documentation bundled inside the &lt;code&gt;next@16.4.5&lt;/code&gt; package settles it for current versions. That package contains the guide with the "no configuration" wording and no longer includes a reference page for the &lt;code&gt;viewTransition&lt;/code&gt; flag at all. On 16.4 and later, there is nothing to switch on. On 16.2 or 16.3, follow the flag documentation for your installed version, which you can read in &lt;code&gt;node_modules/next/dist/docs&lt;/code&gt;. That folder is a reliable tiebreaker whenever the hosted docs and your installed version seem to disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Placement mistakes that silently disable animations
&lt;/h2&gt;

&lt;p&gt;Most "my View Transition does nothing" problems come from where the boundary sits in the tree, not from its props. None of these produces an error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A DOM node above the boundary.&lt;/strong&gt; Enter and exit only activate when the &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; comes before any DOM node in the part of the tree being inserted or removed. Wrapping it in a &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; breaks it. The fix is to move the boundary outside the element:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Does not animate on enter or exit&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="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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Card&lt;/span&gt; &lt;span class="p"&gt;/&amp;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="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// Animates&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="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="p"&gt;&amp;gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Card&lt;/span&gt; &lt;span class="p"&gt;/&amp;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;strong&gt;Boundaries inside layouts.&lt;/strong&gt; As above, layouts persist, so enter and exit never fire there. Directional and page-level animations belong in pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wrappers inside lists.&lt;/strong&gt; Nested boundaries receive their own mutations rather than passing them to the parent, and in a list, the update animation applies to each item only when its &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; sits directly in the list rather than inside a wrapper element. Reordering also needs stable keys. An index used as a key makes React believe every item changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unstable keys on keyed swaps.&lt;/strong&gt; The Next.js guide's tab example relies on &lt;code&gt;key={slug}&lt;/code&gt; to make React treat old and new content as an exit and enter pair. If the key does not change when the content does, React performs an update in place instead.&lt;/p&gt;

&lt;p&gt;This is also where Fragment refs help indirectly. A common reason for a wrapper &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; above a boundary is that some other code needed a DOM node to attach an &lt;code&gt;IntersectionObserver&lt;/code&gt; or an event listener. In React 19.3, a ref on a &lt;code&gt;&amp;lt;Fragment&amp;gt;&lt;/code&gt; gives you a &lt;code&gt;FragmentInstance&lt;/code&gt; with &lt;code&gt;observeUsing&lt;/code&gt;, &lt;code&gt;addEventListener&lt;/code&gt;, &lt;code&gt;focus&lt;/code&gt; and measurement methods that act on the fragment's children as a group, without adding a node. The &lt;a href="https://react.dev/reference/react/Fragment" rel="noopener noreferrer"&gt;&lt;code&gt;&amp;lt;Fragment&amp;gt;&lt;/code&gt; reference&lt;/a&gt; covers the full method list. Removing those wrapper elements is often what lets a boundary sit at the top of its subtree, where it needs to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back navigation, interruptions and flushSync
&lt;/h2&gt;

&lt;p&gt;Three runtime behaviours explain the remaining cases where an animation should play and does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser back and forward.&lt;/strong&gt; The React reference says React skips animations for &lt;code&gt;popstate&lt;/code&gt; navigations, because scroll and form restoration have to finish synchronously, and notes that routers built on the Navigation API avoid the limitation. The Next.js guide describes the result in its own router slightly differently: back and forward gestures carry no transition type, so directional slides do not play, while a shared element morph still applies if both pages have matching names. The two descriptions are about different layers, but they leave the same advice. Never rely on an animation to explain a back navigation, and test the back button and swipe gestures on each browser you support.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A transition already running.&lt;/strong&gt; If one View Transition is in progress, React waits for it to finish, and updates that arrive in the meantime are batched. A long animation therefore delays the next one. This is the practical case for short durations: the Next.js guide's timings sit between 150 and 400 milliseconds, and we would treat anything longer on a frequently used screen as a design decision that needs a reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;flushSync.&lt;/strong&gt; A &lt;code&gt;flushSync&lt;/code&gt; call in the middle of the sequence makes React skip the Transition. Third-party components that call it internally can disable animations in a way that is hard to trace, so when an animation stops working after a library upgrade, search the dependency for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reduced motion and accessibility remain your job
&lt;/h2&gt;

&lt;p&gt;React does not disable View Transitions for users who have asked their operating system to reduce motion. The reference docs recommend always handling the &lt;code&gt;prefers-reduced-motion&lt;/code&gt; media query, and the Next.js guide supplies a blunt but effective default:&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="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;With this in place, content swaps instantly, which is what the browser does without view transitions at all. A finer approach keeps cross-fades and removes positional movement, since large horizontal slides are the most likely to cause discomfort, while opacity changes carry far less risk. Whichever you choose, ship the media query in the same pull request as the first animation, not as a follow-up.&lt;/p&gt;

&lt;p&gt;The overlay matters for keyboard and pointer users too. Named participants, such as an anchored header, are skipped by hit-testing for the duration of a transition even with &lt;code&gt;pointer-events: none&lt;/code&gt; on the overlay, so avoid naming elements that people click quickly and repeatedly. And because animations wait for Transitions, focus management should not depend on an animation finishing: move focus as part of the state change, not in an &lt;code&gt;onEnter&lt;/code&gt; callback.&lt;/p&gt;

&lt;p&gt;If your products serve customers in the EU, motion and focus behaviour are part of the accessibility picture your team is already assessing, and our guide to &lt;a href="https://zignites.com/blog/european-accessibility-act-compliance" rel="noopener noreferrer"&gt;European Accessibility Act compliance&lt;/a&gt; covers where those obligations come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Browser support and progressive enhancement
&lt;/h2&gt;

&lt;p&gt;Same-document view transitions reached Baseline status on 14 October 2025, when Firefox 144 shipped them and all three major browser engines supported the core API. &lt;a href="https://web.dev/blog/same-document-view-transitions-are-now-baseline-newly-available" rel="noopener noreferrer"&gt;web.dev's announcement&lt;/a&gt; also noted that Firefox's first implementation did not include view transition types, which React's integration depends on for typed animations.&lt;/p&gt;

&lt;p&gt;The Next.js guide, updated in October 2026, says React's integration uses the newer parts of the API, namely transition types and &lt;code&gt;view-transition-class&lt;/code&gt;, and lists them as available in Chromium 125 and later and in recent Safari and Firefox versions, with a warning that some animations may behave differently in Safari. Check the current support tables on the day you ship rather than relying on a version number in an article, this one included. The &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API" rel="noopener noreferrer"&gt;MDN View Transition API documentation&lt;/a&gt; links to them.&lt;/p&gt;

&lt;p&gt;The reassuring part is the failure mode. Without browser support, the application works normally and the transitions simply do not animate. View Transitions are progressive enhancement by design, so the risk in an unsupported browser is a plainer experience, not a broken one. That is also the right way to brief a client or product owner: animation is a refinement on top of a working interface, and no user flow should depend on it.&lt;/p&gt;

&lt;p&gt;Two adjacent limits are worth knowing. &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; works only in the DOM today; React Native support is in progress but not available. And because the browser animates snapshots of the old and new content, a transition that covers the whole page asks more of a low-end phone than one that covers a single card. Animate the element that changed, not the whole page, and measure the screens that matter with real devices. If interaction latency is already a concern on your mobile templates, our piece on &lt;a href="https://zignites.com/blog/mobile-first-design-core-web-vitals" rel="noopener noreferrer"&gt;mobile-first design and Core Web Vitals&lt;/a&gt; is the right place to start before adding motion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you replace your animation library?
&lt;/h2&gt;

&lt;p&gt;For route transitions, loading reveals and simple enter and exit effects, &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; is now a credible replacement for a JavaScript animation library, and it has two advantages a library cannot easily match. It is coordinated with React's own scheduling, so animations line up with Suspense and Transitions instead of fighting them. And it runs on a browser API, so there is less JavaScript to download for those effects.&lt;/p&gt;

&lt;p&gt;It is not a replacement for everything. Gesture-driven motion, spring physics, scroll-linked effects and animations that must respond continuously to input are still better served by a dedicated library or by CSS scroll-driven animations. That is our view rather than an official position, and it reflects what each tool is designed for: View Transitions animate between two states, while those effects describe motion that has no fixed end state.&lt;/p&gt;

&lt;p&gt;A sensible migration moves one category at a time. Route transitions first, because the App Router already treats navigations as Transitions. Suspense reveals second, using the rule above. Leave interactive motion where it is until there is a specific reason to change it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A test plan before shipping View Transitions
&lt;/h2&gt;

&lt;p&gt;A View Transition that looks right on a fast laptop in Chrome has passed the easiest test available. Before switching them on in production, we work through these checks on every screen that uses them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Confirm versions.&lt;/strong&gt; &lt;code&gt;npm ls react react-dom next&lt;/code&gt; shows matching React versions at 19.3.1 or later, and Next.js at 16.4.5 or later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;List every boundary.&lt;/strong&gt; Search the codebase for &lt;code&gt;&amp;lt;ViewTransition&lt;/code&gt; and record which triggers each one animates. Anything without &lt;code&gt;default="none"&lt;/code&gt; needs a reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check placement.&lt;/strong&gt; For each boundary that should enter or exit, confirm no DOM node sits above it in the inserted subtree, and that page-level boundaries live in pages rather than layouts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the Suspense rule.&lt;/strong&gt; Throttle the network in DevTools and confirm fallbacks appear instantly, reveals animate, and revisiting cached content does not animate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test shared elements cold and warm.&lt;/strong&gt; Navigate with an empty cache and with prefetched data. The morph should play when prefetched, and fall back to a clean enter animation when not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Click during animations.&lt;/strong&gt; Confirm buttons and links respond while a transition is running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the back button and gestures&lt;/strong&gt; on every supported browser, and confirm nothing depends on an animation to make sense.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on reduced motion&lt;/strong&gt; in the operating system and confirm positional movement stops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the browsers you support&lt;/strong&gt;, including at least one where typed animations may be missing, and confirm the interface still works without animation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check keyboard focus&lt;/strong&gt; after every animated state change.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each of these, with pass criteria and a readiness score, is in the printable audit worksheet below, so the checks can be split across a team and ticked off screen by screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the React View Transitions Audit Worksheet
&lt;/h2&gt;

&lt;p&gt;A two-page printable worksheet with 20 checks across setup, behaviour and release, each with clear pass criteria, plus a readiness score that tells you whether a screen is ready for View Transitions in production. No account, no sales sequence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://zignites.com/contact?resource=react-view-transitions-audit&amp;amp;from=react-view-transitions-suspense" rel="noopener noreferrer"&gt;Get the PDF&lt;/a&gt;&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Do I need a configuration flag to use ViewTransition in Next.js?
&lt;/h3&gt;

&lt;p&gt;Not on Next.js 16.4 or later. The view transitions guide and the documentation bundled with &lt;code&gt;next@16.4.5&lt;/code&gt; say view transitions work in the App Router with no configuration, because React 19.3 provides &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; and &lt;code&gt;addTransitionType&lt;/code&gt;. Older 16.x versions documented an &lt;code&gt;experimental.viewTransition&lt;/code&gt; flag, so check the docs in &lt;code&gt;node_modules/next/dist/docs&lt;/code&gt; for your installed version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my ViewTransition not animate?
&lt;/h3&gt;

&lt;p&gt;The most common causes are an update that is not wrapped in &lt;code&gt;startTransition&lt;/code&gt;, a DOM element placed above the boundary, a boundary inside a layout that never enters or exits, and &lt;code&gt;default="none"&lt;/code&gt; on a shared pair without an explicit &lt;code&gt;share&lt;/code&gt; prop. A &lt;code&gt;flushSync&lt;/code&gt; call during the update also makes React skip the animation. None of these produces an error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does React ViewTransition work in Firefox and Safari?
&lt;/h3&gt;

&lt;p&gt;The core View Transitions API has been supported in all major browser engines since October 2025. React's integration also uses transition types and &lt;code&gt;view-transition-class&lt;/code&gt;, which Next.js lists as available in Chromium 125 and later and in recent Safari and Firefox versions. Where support is missing, the app works normally and simply does not animate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does React turn off View Transitions for reduced motion?
&lt;/h3&gt;

&lt;p&gt;No. React does not check the user's motion preference. Add a &lt;code&gt;prefers-reduced-motion&lt;/code&gt; media query that sets animation durations and delays to zero on the &lt;code&gt;::view-transition-old&lt;/code&gt;, &lt;code&gt;::view-transition-new&lt;/code&gt; and &lt;code&gt;::view-transition-group&lt;/code&gt; pseudo-elements, or keep only opacity changes and remove movement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use ViewTransition with React Native?
&lt;/h3&gt;

&lt;p&gt;Not yet. The React 19.3 announcement says &lt;code&gt;&amp;lt;ViewTransition&amp;gt;&lt;/code&gt; currently works only in the DOM, and that support for React Native and other platforms is being worked on. Fragment refs and the other 19.3 changes are separate from this limitation.&lt;/p&gt;




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

</description>
      <category>react</category>
      <category>nextjs</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>WordPress 7.2 for Plugin Developers: Secrets API, 410s and What to Test</title>
      <dc:creator>Muhammad Zeeshan Sardar</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/zeeshansardar08/wordpress-72-for-plugin-developers-secrets-api-410s-and-what-to-test-2kip</link>
      <guid>https://dev.to/zeeshansardar08/wordpress-72-for-plugin-developers-secrets-api-410s-and-what-to-test-2kip</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; WordPress 7.2 ships on 8 December 2026 and RC1 is on 17 November. Most breaking changes are small: &lt;code&gt;load-scripts.php&lt;/code&gt; and &lt;code&gt;load-styles.php&lt;/code&gt; now return 410 Gone, and &lt;code&gt;wp_kses()&lt;/code&gt; output is being normalised by an HTML API rewrite (7.2 or 7.3). The change worth real time is the new Secrets API, which lets plugins store API keys encrypted instead of as plain text in &lt;code&gt;wp_options&lt;/code&gt;. Below: the schedule, getter, setter and migration code, and a test plan.&lt;/p&gt;

&lt;p&gt;Most of the breaking changes in WordPress 7.2 will cost a well-maintained plugin an afternoon. The change that deserves real engineering time is not a breaking change at all. It is the new Secrets API, and the reason is simple: a plain-text API key sitting in &lt;code&gt;wp_options&lt;/code&gt; is a bigger liability for most plugins than anything 7.2 removes.&lt;/p&gt;

&lt;p&gt;That is the position this guide takes, and it shapes the order of everything below. First the schedule, so you know how much time you have. Then the Secrets API, because it is where the most value sits. Then the changes that can actually break a plugin: retired script concatenation, a rebuilt &lt;code&gt;wp_kses()&lt;/code&gt;, a database session change and the new admin icons. It finishes with the test environment and the test plan we run on any plugin before a major release reaches its first release candidate.&lt;/p&gt;

&lt;p&gt;Every technical detail here was checked against the WordPress 7.2 Beta 1 announcement, the Make WordPress Core posts, the developer blog and the relevant commits on 27 October 2026. 7.2 is still in beta, so function names and behaviour can change before release. Where a feature is not yet confirmed for 7.2, the section says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  WordPress 7.2 release date and beta schedule
&lt;/h2&gt;

&lt;p&gt;WordPress 7.2 is scheduled for release on &lt;strong&gt;8 December 2026&lt;/strong&gt;, and it is the final major release of the year. Beta 1 landed on 20 October and froze the feature list. The &lt;a href="https://make.wordpress.org/core/2026/10/06/wordpress-7-2-release-party-schedule/" rel="noopener noreferrer"&gt;official release party schedule&lt;/a&gt; sets every milestone at 15:00 UTC:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Milestone&lt;/th&gt;
&lt;th&gt;Date (2026)&lt;/th&gt;
&lt;th&gt;What it means for plugin developers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Beta 1&lt;/td&gt;
&lt;td&gt;20 October&lt;/td&gt;
&lt;td&gt;Feature freeze. Start testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Beta 2&lt;/td&gt;
&lt;td&gt;27 October&lt;/td&gt;
&lt;td&gt;Bug fixes from Beta 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Beta 3&lt;/td&gt;
&lt;td&gt;3 November&lt;/td&gt;
&lt;td&gt;Bug fixes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Beta 4&lt;/td&gt;
&lt;td&gt;10 November&lt;/td&gt;
&lt;td&gt;Last beta. Your compatibility fix should be ready&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RC1&lt;/td&gt;
&lt;td&gt;17 November&lt;/td&gt;
&lt;td&gt;Release candidate. Time to update "Tested up to"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RC2&lt;/td&gt;
&lt;td&gt;24 November&lt;/td&gt;
&lt;td&gt;Final fixes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RC3&lt;/td&gt;
&lt;td&gt;1 December&lt;/td&gt;
&lt;td&gt;Final fixes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dry run&lt;/td&gt;
&lt;td&gt;7 December&lt;/td&gt;
&lt;td&gt;24-hour code freeze&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Final release&lt;/td&gt;
&lt;td&gt;8 December&lt;/td&gt;
&lt;td&gt;WordPress 7.2 ships&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The date that matters most is RC1 on 17 November. Before it, a bug report against a beta can still change what ships. After it, you are mostly confirming fixes. If 7.2 breaks something in your plugin and the cause is a regression in core, the beta weeks are the only window where reporting it changes the outcome for everyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes for plugin developers in WordPress 7.2
&lt;/h2&gt;

&lt;p&gt;Seven changes in 7.2 are worth a plugin developer's attention. Some add capability, some remove old behaviour, and two are not yet guaranteed to land in this release.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Status in 7.2&lt;/th&gt;
&lt;th&gt;Who is affected&lt;/th&gt;
&lt;th&gt;Effort&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Secrets API for encrypted credential storage&lt;/td&gt;
&lt;td&gt;In Beta 1&lt;/td&gt;
&lt;td&gt;Any plugin that stores an API key, token or password&lt;/td&gt;
&lt;td&gt;A small sprint, worth it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Script and style concatenation retired&lt;/td&gt;
&lt;td&gt;In Beta 1&lt;/td&gt;
&lt;td&gt;Plugins that link to &lt;code&gt;load-scripts.php&lt;/code&gt; or &lt;code&gt;load-styles.php&lt;/code&gt;, or rely on held output&lt;/td&gt;
&lt;td&gt;An afternoon, or nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;wp_kses()&lt;/code&gt; rebuilt on the HTML API&lt;/td&gt;
&lt;td&gt;Targeting 7.2 or 7.3&lt;/td&gt;
&lt;td&gt;Plugins that sanitise HTML and then compare, store or test the result&lt;/td&gt;
&lt;td&gt;Run your fixtures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;NO_BACKSLASH_ESCAPES&lt;/code&gt; stripped from the database session&lt;/td&gt;
&lt;td&gt;In Beta 1&lt;/td&gt;
&lt;td&gt;Almost no one. Sites that rely on that SQL mode&lt;/td&gt;
&lt;td&gt;Usually nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Core admin icons replaced with SVGs&lt;/td&gt;
&lt;td&gt;In Beta 1&lt;/td&gt;
&lt;td&gt;Admin CSS or scripts that target core menu or toolbar icons&lt;/td&gt;
&lt;td&gt;Visual check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Five &lt;code&gt;@wordpress/components&lt;/code&gt; deprecated&lt;/td&gt;
&lt;td&gt;Deprecated in 7.2, removal planned for 7.4&lt;/td&gt;
&lt;td&gt;Block and admin UIs built with those components&lt;/td&gt;
&lt;td&gt;Plan a replacement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Automated security review of plugin releases&lt;/td&gt;
&lt;td&gt;Live on WordPress.org&lt;/td&gt;
&lt;td&gt;Every plugin in the directory&lt;/td&gt;
&lt;td&gt;Check your committer email&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;a href="https://wordpress.org/news/2026/10/wordpress-7-2-beta-1-is-ready-for-download-and-testing/" rel="noopener noreferrer"&gt;Beta 1 announcement&lt;/a&gt; covers the editor features too: responsive controls, text shadow presets, Notes in the margin, client-side media processing in the Media Library and an "On This Day" dashboard widget. Those matter to site builders, but they rarely break a plugin. The sections below stay with the changes that touch plugin code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Secrets API: encrypted storage for API keys
&lt;/h2&gt;

&lt;p&gt;The Secrets API gives plugins a standard way to store credentials encrypted at rest instead of in plain text. It is the most useful addition to WordPress for plugin developers in years, and it arrives without breaking anything.&lt;/p&gt;

&lt;p&gt;The problem it solves is familiar to anyone who has built an integration. A plugin that talks to Stripe, an SMTP service, WhatsApp or an AI provider needs to keep a key somewhere. The safe option has always been a constant in &lt;code&gt;wp-config.php&lt;/code&gt;, but most site owners cannot or will not edit that file. So the key ends up in &lt;code&gt;wp_options&lt;/code&gt; as plain text, and from there it travels into every database backup, every staging copy and every SQL dump a support engineer is ever sent. One leaked backup is one leaked key.&lt;/p&gt;

&lt;p&gt;The Beta 1 announcement describes the new API as supporting encryption at rest, credential rotation and customisable storage providers. It was committed to trunk under &lt;a href="https://core.trac.wordpress.org/ticket/66187" rel="noopener noreferrer"&gt;Trac #66187&lt;/a&gt; on 13 October 2026, following the &lt;a href="https://make.wordpress.org/core/2026/08/25/proposal-a-secrets-api-for-wordpress-7-2/" rel="noopener noreferrer"&gt;Secrets API proposal&lt;/a&gt; published in August. The function-level behaviour below comes from that ticket and a &lt;a href="https://w4dev.com/wp/wordpress-secrets-api/" rel="noopener noreferrer"&gt;hands-on test of the committed code on trunk&lt;/a&gt;, since the official developer note had not been published when this was written.&lt;/p&gt;

&lt;p&gt;The key points for a plugin developer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;wp_set_secret( $name, $value )&lt;/code&gt; stores a value encrypted and returns &lt;code&gt;true&lt;/code&gt; or a &lt;code&gt;WP_Error&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wp_get_secret( $name )&lt;/code&gt; returns one of three things: a &lt;code&gt;WP_Secret&lt;/code&gt; object, &lt;code&gt;null&lt;/code&gt; if nothing was ever saved, or a &lt;code&gt;WP_Error&lt;/code&gt; if a value exists but cannot be decrypted.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;WP_Secret&lt;/code&gt; object does not print its value. Echoing it, JSON-encoding it or dumping it shows a placeholder. Serialising or cloning it throws an exception. The only way to the plain value is &lt;code&gt;$secret-&amp;gt;reveal()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Values are encrypted with libsodium (XChaCha20-Poly1305), and the key that unlocks them is derived from &lt;code&gt;wp-config.php&lt;/code&gt;, never stored in the database. A stolen database on its own yields ciphertext.&lt;/li&gt;
&lt;li&gt;Secret names follow a &lt;code&gt;plugin-slug/key-name&lt;/code&gt; pattern using lowercase letters, numbers, hyphens and underscores.&lt;/li&gt;
&lt;li&gt;The functions do not check capabilities, because they must work from cron and REST requests. Your settings screen still has to check &lt;code&gt;current_user_can()&lt;/code&gt; and a nonce. Core adds a &lt;code&gt;manage_secrets&lt;/code&gt; capability you can use.&lt;/li&gt;
&lt;li&gt;There is no admin screen for secrets in 7.2. It is planned for a later release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The namespace in the name is for organisation, not access control. Any plugin running on the site can read any secret. That is an honest limitation, and it is the right one: the API protects credentials from leaked databases and backups, not from malicious code already running inside WordPress.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to read and save a key with the Secrets API on any WordPress version
&lt;/h2&gt;

&lt;p&gt;Your plugin will support versions below 7.2 for a long time, so the integration has to feature-detect the API and fall back cleanly. The pattern we use keeps three sources in a fixed order of trust: a constant first, the Secrets API second, the old option last.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;acme_sync_get_api_key&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 1. A constant in wp-config.php is still the strongest option.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;defined&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'ACME_SYNC_API_KEY'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="no"&gt;ACME_SYNC_API_KEY&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="no"&gt;ACME_SYNC_API_KEY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 2. WordPress 7.2 and later.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;function_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_get_secret'&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="nv"&gt;$secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_get_secret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'acme-sync/api-key'&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="nv"&gt;$secret&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="n"&gt;WP_Secret&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;reveal&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;is_wp_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$value&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="nf"&gt;is_wp_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Stored but unreadable. Surface an admin notice.&lt;/span&gt;
            &lt;span class="c1"&gt;// Never fall back to a plain-text copy here.&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// 3. Older WordPress, or a site that has not migrated yet.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'acme_sync_api_key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&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;function&lt;/span&gt; &lt;span class="n"&gt;acme_sync_save_api_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Capability and nonce checks belong in the calling form or route.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;function_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_set_secret'&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="nf"&gt;wp_set_secret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'acme-sync/api-key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&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;update_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'acme_sync_api_key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in that code are deliberate.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;WP_Error&lt;/code&gt; branch returns an empty string instead of trying the old option. If a site's salts change, every secret becomes unreadable, and silently reading a stale plain-text copy would hide the problem while putting the leaked value back into use. The correct response is a visible notice asking the administrator to re-enter the key.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;null&lt;/code&gt; and &lt;code&gt;WP_Error&lt;/code&gt; cases are handled separately because they mean different things. &lt;code&gt;null&lt;/code&gt; means "never connected", so showing your setup screen is right. &lt;code&gt;WP_Error&lt;/code&gt; means "connected, but something is wrong with the encryption keys", and resetting the plugin to its setup screen would confuse the site owner and lose context.&lt;/p&gt;

&lt;p&gt;Call &lt;code&gt;reveal()&lt;/code&gt; as late as possible, right before the HTTP request that needs the key, and keep the result in a local variable. Do not cache it, log it or pass it through filters. A useful side effect of this design is that every place your plugin touches a credential becomes searchable: look for &lt;code&gt;-&amp;gt;reveal()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating existing keys out of wp_options
&lt;/h2&gt;

&lt;p&gt;Core will not move your plugin's existing options for you. It provides a helper, &lt;code&gt;wp_import_option_as_secret()&lt;/code&gt;, which copies an option into a secret, leaves the original option in place and marks the secret as needing rotation. Removing the old option is your job, and it should only happen after you have confirmed the copy reads back correctly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;acme_sync_migrate_api_key&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&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="nb"&gt;function_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_import_option_as_secret'&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;}&lt;/span&gt;

    &lt;span class="nv"&gt;$legacy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'acme_sync_api_key'&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="nb"&gt;is_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$legacy&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$legacy&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="c1"&gt;// Nothing to migrate, or already done.&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="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nf"&gt;wp_get_secret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'acme-sync/api-key'&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="c1"&gt;// Never import over an existing secret.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$imported&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_import_option_as_secret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'acme_sync_api_key'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'acme-sync/api-key'&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;is_wp_error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$imported&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="c1"&gt;// Try again on the next upgrade run.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_get_secret&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'acme-sync/api-key'&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="nv"&gt;$secret&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="n"&gt;WP_Secret&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$legacy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;reveal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;delete_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'acme_sync_api_key'&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;Run this from your plugin's upgrade routine, not on every request. The guard against importing over an existing secret matters: a second import moves the current value into the "previous" slot, which is not what you want.&lt;/p&gt;

&lt;p&gt;The "needs rotation" flag is the part most developers will be tempted to ignore, and it is the part that matters most. Encrypting a key today does nothing for the plain-text copies already sitting in last month's backups. The real fix is a fresh key from the provider. After migrating, tell your users clearly that they should generate a new key and paste it in, and explain why in one sentence. Site Health lists secrets that still need rotation, so the reminder does not depend on your plugin alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  WP_SECRETS_KEY, rotation and what the Secrets API does not protect
&lt;/h2&gt;

&lt;p&gt;Three operational details decide whether the Secrets API helps or causes support tickets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The site key.&lt;/strong&gt; By default, the encryption key chain starts from the &lt;code&gt;LOGGED_IN_KEY&lt;/code&gt; and &lt;code&gt;LOGGED_IN_SALT&lt;/code&gt; values in &lt;code&gt;wp-config.php&lt;/code&gt;. A dedicated &lt;code&gt;WP_SECRETS_KEY&lt;/code&gt; constant overrides that, and Site Health recommends one, because changing the salts (a common step after a compromise) otherwise makes every secret unreadable. The catch is timing: adding &lt;code&gt;WP_SECRETS_KEY&lt;/code&gt; to a site that already has secrets changes the site key, and every existing secret then fails to decrypt. Nothing falls back to plain text, which is correct, but the result is still a broken integration. If your documentation tells users to set the constant, tell them to do it before saving any keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rotation.&lt;/strong&gt; Each secret keeps two slots, current and previous. Saving a new value moves the old one into the previous slot, so you can switch providers' keys without downtime: try the new one, fall back to the previous while the provider catches up, then call &lt;code&gt;wp_retire_secret_version()&lt;/code&gt; to remove the old value. Nothing retires it automatically, because core cannot know when your provider stopped accepting it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does not protect.&lt;/strong&gt; The API is a hardening layer, not a sandbox. It protects against leaked database dumps, SQL injection that reads &lt;code&gt;wp_options&lt;/code&gt;, staging copies and keys appearing in debug output. It does not protect against a malicious plugin, an administrator who can run arbitrary code, or anyone who holds both the database and &lt;code&gt;wp-config.php&lt;/code&gt;, such as a full-site backup archive. For sites where you control the server, a constant or an environment variable is still the stronger option. The Secrets API exists for the large majority of sites where the administrator pastes the key into a settings screen.&lt;/p&gt;

&lt;p&gt;Hosts can also replace the storage entirely through a &lt;code&gt;secrets.php&lt;/code&gt; drop-in, moving credentials into a managed vault. If that store is read-only, saving fails, so check &lt;code&gt;wp_secrets_provider_is_writable()&lt;/code&gt; before showing an editable key field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Script and style concatenation is retired: what breaks
&lt;/h2&gt;

&lt;p&gt;WordPress 7.2 no longer concatenates admin and login-screen scripts and styles through &lt;code&gt;load-scripts.php&lt;/code&gt; and &lt;code&gt;load-styles.php&lt;/code&gt;. Every script and stylesheet is now printed as its own tag, and the two loader files respond with &lt;strong&gt;410 Gone&lt;/strong&gt; without loading WordPress.&lt;/p&gt;

&lt;p&gt;Concatenation made sense when browsers opened few connections per host over HTTP/1.1. It had also been a long-running source of bugs, from ignored &lt;code&gt;SCRIPT_DEBUG&lt;/code&gt; settings to duplicated inline styles. Core turned it off by default earlier in the cycle and began prefetching the assets of the next admin screen instead. The &lt;a href="https://github.com/WordPress/wordpress-develop/pull/14463" rel="noopener noreferrer"&gt;commit that retired it&lt;/a&gt; landed on 19 October under Trac #57548, the day before Beta 1.&lt;/p&gt;

&lt;p&gt;For most plugins, nothing changes. The breakage is concentrated in three patterns:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Linking to the loaders directly.&lt;/strong&gt; Some plugins copied a &lt;code&gt;load-scripts.php&lt;/code&gt; or &lt;code&gt;load-styles.php&lt;/code&gt; URL from an admin page's source years ago to style a dialog, a preview window or a setup screen. Those requests now get a 410, so the page loses its styles or scripts. The commit notes that around 45 plugins in the directory still do this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relying on held output.&lt;/strong&gt; A few plugins set the &lt;code&gt;do_concat&lt;/code&gt; property to &lt;code&gt;true&lt;/code&gt;, or return &lt;code&gt;false&lt;/code&gt; from the print filters, so core holds markup back and the plugin prints it elsewhere. Items are now always printed immediately, so this no longer works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using the print filters for their return value.&lt;/strong&gt; &lt;code&gt;print_head_scripts&lt;/code&gt;, &lt;code&gt;print_footer_scripts&lt;/code&gt;, &lt;code&gt;print_admin_styles&lt;/code&gt; and &lt;code&gt;print_late_styles&lt;/code&gt; are now applied through &lt;code&gt;apply_filters_deprecated()&lt;/code&gt;. Callbacks still run, with a deprecation notice, but their return values are ignored.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Other things keep working. Defining &lt;code&gt;CONCATENATE_SCRIPTS&lt;/code&gt; has no effect any more because nothing reads it, so plugins that set it to &lt;code&gt;false&lt;/code&gt; are fine. The deprecated properties are still declared, so reading them raises no notice. &lt;code&gt;script_concat_settings()&lt;/code&gt;, &lt;code&gt;_print_scripts()&lt;/code&gt;, &lt;code&gt;_print_styles()&lt;/code&gt;, &lt;code&gt;in_default_dir()&lt;/code&gt; and &lt;code&gt;WP_Dependencies::get_etag()&lt;/code&gt; are deprecated but still callable, with a notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to find concatenation dependencies in your plugin
&lt;/h2&gt;

&lt;p&gt;A single search across your codebase tells you whether any of this applies. Run it from the plugin's root directory:&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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s2"&gt;"load-(scripts|styles)&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;php|CONCATENATE_SCRIPTS|do_concat|print_html|script_concat_settings|_print_(scripts|styles)&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;|print_(head|footer)_scripts|print_(admin|late)_styles|in_default_dir"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.php &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.js &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.html &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then sort each match into one of three buckets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A loader URL&lt;/strong&gt; in a template, dialog or inline HTML. Replace it. In a page that runs inside WordPress, enqueue the handles you need with &lt;code&gt;wp_enqueue_script()&lt;/code&gt; and &lt;code&gt;wp_enqueue_style()&lt;/code&gt;. In a standalone page that prints its own document, call &lt;code&gt;wp_print_scripts()&lt;/code&gt; and &lt;code&gt;wp_print_styles()&lt;/code&gt; with the handles instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;do_concat&lt;/code&gt; set to &lt;code&gt;true&lt;/code&gt;, or a print filter returning &lt;code&gt;false&lt;/code&gt;&lt;/strong&gt;. Rewrite the logic. If you were capturing markup to move it, enqueue in the right place instead, or use the &lt;code&gt;script_loader_tag&lt;/code&gt; and &lt;code&gt;style_loader_tag&lt;/code&gt; filters to adjust individual tags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything else&lt;/strong&gt; (setting &lt;code&gt;do_concat&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt;, reading &lt;code&gt;default_dirs&lt;/code&gt;, defining &lt;code&gt;CONCATENATE_SCRIPTS&lt;/code&gt;). This works, but clean it up when convenient, since the code no longer does anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bundled third-party code deserves a look too. A match inside a vendor SDK is still your plugin's problem once it ships in your zip. If you want a quick picture of how many active plugins on a client site might be affected by changes like this, our &lt;a href="https://zignites.com/tools/wordpress-plugin-risk-report" rel="noopener noreferrer"&gt;WordPress Plugin Risk Report&lt;/a&gt; lists what a site runs and how recently each plugin was maintained, which is usually the best predictor of who will fix things in time.&lt;/p&gt;

&lt;h2&gt;
  
  
  wp_kses() on the HTML API: sanitised output will look different
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;wp_kses()&lt;/code&gt;, the function behind &lt;code&gt;wp_kses_post()&lt;/code&gt; and most HTML sanitisation in WordPress, is being rebuilt on the HTML API. The &lt;a href="https://make.wordpress.org/core/2026/10/07/progress-report-wp_kses/" rel="noopener noreferrer"&gt;progress report&lt;/a&gt; targets 7.2 or 7.3, depending on what testing turns up. It is not guaranteed for this release, but if it lands, the output of sanitised HTML changes in ways your code can notice.&lt;/p&gt;

&lt;p&gt;The interface stays the same. What changes is the shape of the result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Output is normalised.&lt;/strong&gt; Tags come back lowercase, attributes are double-quoted, the self-closing slash on void elements is dropped and character references are decoded and re-escaped consistently. An &lt;code&gt;&amp;lt;IMG class=x /&amp;gt;&lt;/code&gt; input becomes &lt;code&gt;&amp;lt;img class="x"&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disallowed &lt;code&gt;script&lt;/code&gt; and &lt;code&gt;style&lt;/code&gt; elements are removed with their contents.&lt;/strong&gt; The legacy implementation stripped the tags and left the code behind as visible text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Text that only looked like a tag is kept.&lt;/strong&gt; Input such as "under &amp;lt;$500" used to swallow text; it is now escaped and preserved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incomplete trailing markup is dropped.&lt;/strong&gt; A fragment that ends mid-tag loses that tail instead of having it escaped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Complex SVG and MathML is cut off.&lt;/strong&gt; Embedded SVG or MathML that needs full tree-aware parsing is rejected from that point onward.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plugins that only sanitise and output HTML will rarely notice. The risk sits with code that sanitises and then &lt;strong&gt;compares&lt;/strong&gt; the result, such as change detection, cache keys or hashes built from sanitised content, and with unit tests that assert exact HTML strings. The Core team suggests &lt;code&gt;assertEqualHTML()&lt;/code&gt; for tests, which compares HTML semantically rather than byte for byte.&lt;/p&gt;

&lt;p&gt;There is an escape hatch, &lt;code&gt;add_filter( 'wp_kses_force_legacy_parser', '__return_true' );&lt;/code&gt;, which restores the old implementation. Treat it as a way to keep a site running while you fix your code, not as a fix. The report is explicit that the legacy parser will be removed, possibly as early as 7.2.0, and any problems belong on Trac #66208 while there is still time to address them.&lt;/p&gt;

&lt;h2&gt;
  
  
  NO_BACKSLASH_ESCAPES is now stripped from the database session
&lt;/h2&gt;

&lt;p&gt;From 7.2, &lt;code&gt;wpdb&lt;/code&gt; removes the &lt;code&gt;NO_BACKSLASH_ESCAPES&lt;/code&gt; SQL mode from WordPress's own database session. The &lt;a href="https://make.wordpress.org/core/2026/10/09/no_backslash_escapes-sql-mode-is-now-turned-off-by-default-in-wordpress-7-2/" rel="noopener noreferrer"&gt;developer note&lt;/a&gt; explains why: WordPress escapes special characters with backslashes, and when that mode is enabled on the server, MySQL treats the backslash as a literal character, so escaped queries fail or return wrong data. Some hosts enable it, and the resulting bugs are hard to trace.&lt;/p&gt;

&lt;p&gt;For nearly every plugin this is a fix rather than a risk. It only affects WordPress's session, not the server's global settings, and plugins that use &lt;code&gt;$wpdb-&amp;gt;prepare()&lt;/code&gt; get more predictable behaviour.&lt;/p&gt;

&lt;p&gt;If a site genuinely depends on that mode, it can be kept with the existing &lt;code&gt;incompatible_sql_modes&lt;/code&gt; filter, but only from a &lt;code&gt;db.php&lt;/code&gt; drop-in. WordPress connects to the database before plugins and must-use plugins load, so a filter added in a plugin runs too late. If your plugin opens its own database connection with a separate driver, this change does not touch it, and that connection keeps whatever mode the server sets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Admin icons, Core icons and deprecated components
&lt;/h2&gt;

&lt;p&gt;The visual changes in 7.2 will not throw errors, but they can make a plugin's admin screens look broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Core admin menu and toolbar icons are now SVGs.&lt;/strong&gt; WordPress 7.2 replaces the Dashicons in the admin bar and sidebar with icons from the WordPress icon library, rendered with &lt;code&gt;wp_get_icon()&lt;/code&gt; from 7.1. The &lt;a href="https://make.wordpress.org/core/2026/09/04/replacing-dashicons-in-the-admin-bar-and-menu/" rel="noopener noreferrer"&gt;Make Core post on the change&lt;/a&gt; explains the accessibility reasoning: icon fonts are read inconsistently by screen readers and can vanish in forced-colour modes. If your plugin's admin CSS or JavaScript targets the icon markup of core menu items or toolbar nodes, check those screens. A block's &lt;code&gt;block.json&lt;/code&gt; icon slug still falls back to a Dashicon, so block icons are unaffected for now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Core icons use strokes, not fills.&lt;/strong&gt; Roughly 330 icons in &lt;code&gt;@wordpress/icons&lt;/code&gt; were redrawn as stroke-based SVGs, and each sets &lt;code&gt;fill: none&lt;/code&gt; inline. A &lt;code&gt;fill&lt;/code&gt; prop or CSS &lt;code&gt;fill&lt;/code&gt; rule no longer recolours them; use CSS &lt;code&gt;color&lt;/code&gt; instead. The &lt;a href="https://developer.wordpress.org/news/2026/10/whats-new-for-developers-october-2026/" rel="noopener noreferrer"&gt;October developer roundup&lt;/a&gt; also advises updating &lt;code&gt;@wordpress/icons&lt;/code&gt; and &lt;code&gt;@wordpress/components&lt;/code&gt; together, because mixing new icons with an older Icon component can drop the merged styles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Five components are deprecated.&lt;/strong&gt; &lt;code&gt;Divider&lt;/code&gt;, &lt;code&gt;ResponsiveWrapper&lt;/code&gt;, &lt;code&gt;Scrollable&lt;/code&gt;, &lt;code&gt;ZStack&lt;/code&gt; and &lt;code&gt;Elevation&lt;/code&gt; in &lt;code&gt;@wordpress/components&lt;/code&gt; are marked deprecated since 7.2, with removal planned for 7.4. The suggested replacements are plain CSS (for example &lt;code&gt;aspect-ratio&lt;/code&gt; instead of &lt;code&gt;ResponsiveWrapper&lt;/code&gt;) and the &lt;code&gt;Separator&lt;/code&gt; component instead of &lt;code&gt;Divider&lt;/code&gt;. In &lt;code&gt;MediaUpload&lt;/code&gt;, &lt;code&gt;unstableFeaturedImageFlow&lt;/code&gt; is now &lt;code&gt;featuredImageFlow&lt;/code&gt;, with the old name on the same removal path. Nothing breaks in 7.2, but this is the release to schedule the replacement.&lt;/p&gt;

&lt;p&gt;Two smaller notes for editor integrations: the post sidebar is being rebuilt on DataForm, and the roundup warns that &lt;code&gt;PluginPostExcerpt&lt;/code&gt; may not survive the move. The React 19 upgrade is listed on the roadmap but described as unlikely to fully land in 7.2.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plugin directory releases now pass an automated security review
&lt;/h2&gt;

&lt;p&gt;Shipping a 7.2 compatibility update now involves one more step outside your code. The WordPress.org Plugins team introduced an &lt;a href="https://make.wordpress.org/plugins/2026/09/09/automated-security-review-for-plugin-releases/" rel="noopener noreferrer"&gt;automated security review for plugin releases&lt;/a&gt;: during the release cooldown, several AI models and Jetpack Scan score each new release. High-risk releases are blocked automatically, and committers receive an email with the findings.&lt;/p&gt;

&lt;p&gt;Two practical consequences follow. First, make sure the email address on every committer account is one that somebody actually reads, because a blocked release you do not hear about is a compatibility fix that never reaches users. Second, do not leave your 7.2 update until the week of the final release. Give the release room to be reviewed, and if anything is flagged, room to address it before 8 December. This is one more reason the work belongs in the beta window.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to set up a WordPress 7.2 test environment
&lt;/h2&gt;

&lt;p&gt;The fastest useful setup is a staging copy of a real site with your plugin configured as a customer would have it, upgraded to the latest 7.2 beta. A clean install catches fatal errors. A realistic one catches the problems your users will actually report. Never install a beta on a production site.&lt;/p&gt;

&lt;p&gt;The Beta 1 announcement lists four ways to get 7.2:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The WordPress Beta Tester plugin.&lt;/strong&gt; Choose the "Bleeding edge" channel and the "Beta/RC Only" stream, then update from Dashboard, Updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WP-CLI.&lt;/strong&gt; &lt;code&gt;wp core update --version=7.2-beta1&lt;/code&gt;, replacing the version string with the latest beta as each one is released.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A direct download&lt;/strong&gt; of the beta zip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WordPress Playground&lt;/strong&gt;, which runs the beta in your browser with no setup. It is ideal for a first look, and its new terminal can run PHP and WP-CLI commands against the live instance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Test on at least two PHP versions: the lowest your plugin supports and the newest your users run. WordPress itself has required PHP 7.4 or later since 7.0, which &lt;a href="https://make.wordpress.org/core/2026/01/09/dropping-support-for-php-7-2-and-7-3/" rel="noopener noreferrer"&gt;dropped support for PHP 7.2 and 7.3&lt;/a&gt;. Turn on &lt;code&gt;WP_DEBUG&lt;/code&gt; and &lt;code&gt;WP_DEBUG_LOG&lt;/code&gt; so deprecation notices from the concatenation changes are written to the log rather than missed.&lt;/p&gt;

&lt;p&gt;Keep a second environment on 7.1 alongside it. Several checks below compare output between versions, and a side-by-side diff is far quicker than reasoning about what changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  A test plan for WordPress 7.2 compatibility
&lt;/h2&gt;

&lt;p&gt;A plugin that activates without a fatal error on 7.2 has passed the smallest possible test. Before RC1, we work through this list for every plugin we maintain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Activate, deactivate and upgrade.&lt;/strong&gt; Activate the plugin on 7.2, then run your upgrade routine from the previous release with real data, and confirm the debug log stays clean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run the concatenation search.&lt;/strong&gt; Use the search command above and resolve every loader URL and every reliance on held output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open every admin screen.&lt;/strong&gt; Load each settings page, dialog, preview window and setup wizard with the browser's network panel open. Any request to &lt;code&gt;load-scripts.php&lt;/code&gt; or &lt;code&gt;load-styles.php&lt;/code&gt; returning 410 is a bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff sanitised output.&lt;/strong&gt; Push a set of real HTML fixtures through &lt;code&gt;wp_kses_post()&lt;/code&gt; on 7.1 and on 7.2, and compare the results. A quick way with WP-CLI is &lt;code&gt;wp eval 'echo wp_kses_post( file_get_contents( "fixture.html" ) );'&lt;/code&gt; on each install. Then repeat the run with the HTML API parser forced on, in case it lands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-run your test suite.&lt;/strong&gt; Pay attention to assertions on exact HTML strings and switch them to semantic comparison where the difference is cosmetic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exercise every query path.&lt;/strong&gt; Run search, filters and any custom SQL with values containing quotes and backslashes, and confirm the results match 7.1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the admin visually.&lt;/strong&gt; Look at any screen where your CSS touches core menu items, the toolbar or icons, and check icon colours in both light and dark admin colour schemes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search for deprecated components.&lt;/strong&gt; Look for &lt;code&gt;Divider&lt;/code&gt;, &lt;code&gt;ResponsiveWrapper&lt;/code&gt;, &lt;code&gt;Scrollable&lt;/code&gt;, &lt;code&gt;ZStack&lt;/code&gt;, &lt;code&gt;Elevation&lt;/code&gt; and &lt;code&gt;unstableFeaturedImageFlow&lt;/code&gt;, and plan their replacements before 7.4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prototype the Secrets API.&lt;/strong&gt; Wire the getter, setter and migration into a branch, test all three return types, and test what happens when the site key changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report anything that looks like a core regression&lt;/strong&gt; on Trac or in the Alpha/Beta support forum, with steps to reproduce, while there is still time to fix it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each of these, along with pass criteria and a readiness score, is in the printable worksheet below, so a team can split the work and tick it off plugin by plugin.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to update "Tested up to" and ship
&lt;/h2&gt;

&lt;p&gt;Update the "Tested up to" header in your &lt;code&gt;readme.txt&lt;/code&gt; once you have tested against RC1, not before. Raising it during the beta tells users you have tested a version that may still change. Leaving it until after 8 December means that, from the day 7.2 ships, your plugin's directory page warns users it has not been tested with the latest major release.&lt;/p&gt;

&lt;p&gt;A sensible sequence looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;During the betas:&lt;/strong&gt; fix anything the test plan finds, and ship those fixes in a normal release. Fixes that work on both 7.1 and 7.2 do not need to wait for anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At RC1 on 17 November:&lt;/strong&gt; rerun the plan against the release candidate and raise "Tested up to" to 7.2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before 8 December:&lt;/strong&gt; ship the release that includes the updated header, leaving time for the automated security review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Secrets API is a separate track. Because it is purely additive and feature-detected, you can ship the getter and setter as soon as 7.2 is released, then run the migration in a later version once you are confident in it. There is no reason to rush a credential migration into the same release as a compatibility fix. If you build integrations for clients, this is also a good moment to look at how your plugins handle credentials more broadly, and our &lt;a href="https://zignites.com/blog/wordpress-plugin-development-guide" rel="noopener noreferrer"&gt;guide to WordPress plugin development&lt;/a&gt; covers the settings, security and release practices that make changes like these routine. For a plugin that needs this work done properly and quickly, our &lt;a href="https://zignites.com/services/wordpress-plugin-development" rel="noopener noreferrer"&gt;WordPress plugin development service&lt;/a&gt; covers compatibility updates and Secrets API migrations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the WordPress 7.2 Plugin Compatibility Worksheet
&lt;/h2&gt;

&lt;p&gt;A two-page printable worksheet with 22 checks across setup, testing and release, each with clear pass criteria, plus a readiness score that tells you whether a plugin is ready for 8 December. No account, no sales sequence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://zignites.com/contact?resource=wp72-plugin-compat&amp;amp;from=wordpress-7-2-plugin-developers" rel="noopener noreferrer"&gt;Get the PDF&lt;/a&gt;&lt;/p&gt;

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

&lt;h3&gt;
  
  
  When is WordPress 7.2 released?
&lt;/h3&gt;

&lt;p&gt;WordPress 7.2 is scheduled for release on 8 December 2026 at 15:00 UTC. Beta 1 arrived on 20 October, with further betas on 27 October, 3 November and 10 November. The first release candidate is due on 17 November, followed by RC2 on 24 November and RC3 on 1 December.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is WordPress 7.2 related to PHP 7.2?
&lt;/h3&gt;

&lt;p&gt;No. WordPress 7.2 is a version of WordPress, and PHP 7.2 is an old version of the PHP language. They share a number by coincidence. WordPress dropped support for PHP 7.2 and 7.3 in WordPress 7.0, so WordPress 7.2 needs PHP 7.4 or later, and a current PHP 8 release is the better choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will WordPress 7.2 break my plugin?
&lt;/h3&gt;

&lt;p&gt;Most plugins will keep working. The changes most likely to cause problems are the retired script concatenation, which breaks plugins that link to &lt;code&gt;load-scripts.php&lt;/code&gt; or &lt;code&gt;load-styles.php&lt;/code&gt; directly, and the rebuilt &lt;code&gt;wp_kses()&lt;/code&gt;, which changes the exact output of sanitised HTML if it lands in 7.2. Testing against the betas before RC1 on 17 November is the reliable way to know.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I have to use the WordPress Secrets API?
&lt;/h3&gt;

&lt;p&gt;No. The Secrets API is optional, and existing options keep working. It is strongly worth adopting if your plugin stores API keys or tokens, because it keeps them encrypted in the database and out of plain-text backups. Feature-detect it with &lt;code&gt;function_exists( 'wp_get_secret' )&lt;/code&gt; so your plugin still runs on older WordPress versions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does CONCATENATE_SCRIPTS still work in WordPress 7.2?
&lt;/h3&gt;

&lt;p&gt;No. WordPress 7.2 no longer reads the &lt;code&gt;CONCATENATE_SCRIPTS&lt;/code&gt; constant, because scripts and styles are no longer concatenated at all. Defining it is harmless but has no effect, so you can remove it from &lt;code&gt;wp-config.php&lt;/code&gt; and from any plugin code when convenient.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will the new wp_kses() ship in WordPress 7.2?
&lt;/h3&gt;

&lt;p&gt;It is not certain. The Core team targets WordPress 7.2 or 7.3, depending on what testing reveals. Test your plugin against it now either way, and if something breaks, the &lt;code&gt;wp_kses_force_legacy_parser&lt;/code&gt; filter restores the old behaviour temporarily while you fix it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://zignites.com/blog/wordpress-7-2-plugin-developers" rel="noopener noreferrer"&gt;zignites.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>Shopify Next Gen Events: Migrate Off Webhooks Without Losing Deliveries</title>
      <dc:creator>Muhammad Zeeshan Sardar</dc:creator>
      <pubDate>Thu, 22 Oct 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/zeeshansardar08/shopify-next-gen-events-migrate-off-webhooks-without-losing-deliveries-28id</link>
      <guid>https://dev.to/zeeshansardar08/shopify-next-gen-events-migrate-off-webhooks-without-losing-deliveries-28id</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Next Gen Events (GA 1 October 2026, API version 2026-10) lets a Shopify app subscribe to exact field changes and get the data it needs in the delivery. The config is the easy part. Triggers only fire for fields you list, and a &lt;code&gt;query_filter&lt;/code&gt; suppresses deliveries by design, so a clean deploy proves very little. Test every state change in both directions, dedupe on &lt;code&gt;Shopify-Webhook-Id&lt;/code&gt;, respond within five seconds, and keep a reconciliation job running.&lt;/p&gt;

&lt;p&gt;Shopify's new event system makes most apps cheaper to run. It also makes it easier than ever to stop receiving something you rely on and not notice for weeks.&lt;/p&gt;

&lt;p&gt;That trade sits at the centre of this migration. Classic webhooks are noisy but honest: something changed on the resource, so you hear about it, and your handler works out what. Next Gen Events flips the contract. You declare in advance exactly which field changes you care about, what data should come with them, and which results should be dropped. Shopify then does precisely that. If the declaration is wrong, nothing errors. The deliveries simply never arrive.&lt;/p&gt;

&lt;p&gt;Next Gen Events became generally available on 1 October 2026 with API version 2026-10. Classic webhooks still work, nothing has a sunset date yet, and both systems can run inside the same app. So there is no deadline forcing anyone's hand. That is the best possible time to migrate, because you can do it one workflow at a time and prove each step before you remove the old one.&lt;/p&gt;

&lt;p&gt;This guide covers how a subscription is built, where deliveries get silently suppressed, the limits that will reject your config at deploy time, and the order we would move workflows in. Every technical detail below was checked against Shopify's documentation for API version 2026-10 on 19 October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Next Gen Events actually changes
&lt;/h2&gt;

&lt;p&gt;Next Gen Events replaces "a resource changed" with "this specific field changed, and here is the data you would have fetched next". That is the whole idea, and it changes three things for an app.&lt;/p&gt;

&lt;p&gt;First, deliveries become targeted. Instead of subscribing to &lt;code&gt;products/update&lt;/code&gt; and receiving a payload every time anything on a product moves, you subscribe to the exact field paths that matter, such as a variant's price or a product's status. Shopify's &lt;a href="https://shopify.dev/changelog/blog/next-generation-events-are-now-generally-available" rel="noopener noreferrer"&gt;GA announcement&lt;/a&gt; describes this as fewer deliveries, richer payloads and fewer follow-up API calls.&lt;/p&gt;

&lt;p&gt;Second, the payload carries its own context. Each delivery includes a &lt;code&gt;fields_changed&lt;/code&gt; summary listing what was added, updated or removed, plus the result of a GraphQL query you define. For most handlers, that removes the "receive webhook, then call the Admin API to find out what actually happened" round trip.&lt;/p&gt;

&lt;p&gt;Third, configuration moves into &lt;code&gt;shopify.app.toml&lt;/code&gt;. Subscriptions are declared under an &lt;code&gt;[events]&lt;/code&gt; section with their own API version, deployed with the rest of the app configuration, and validated at deploy time.&lt;/p&gt;

&lt;p&gt;Coverage at GA is 18 topics across merchandising, customers and companies, orders and fulfilment, inventory and locations, content, and custom data. That includes Product, Collection, Customer, Company, Order, FulfillmentOrder, Refund, Return, InventoryItem, Location, Article, Page, Metaobject and the metafield definition types. Anything Events does not cover yet stays on classic webhooks, which is one more reason to plan for a period where both systems run side by side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the real migration risk is suppression, not configuration
&lt;/h2&gt;

&lt;p&gt;The arguable claim of this post is simple: in a Next Gen Events migration, a config that deploys cleanly is not evidence that it works. The dangerous failures do not throw errors. They drop deliveries.&lt;/p&gt;

&lt;p&gt;Classic webhooks fail loudly. If your endpoint is down, deliveries fail and show up in the logs. If your handler crashes, you see the exception. The surface area of "silently missing data" was mostly limited to the delivery itself going wrong.&lt;/p&gt;

&lt;p&gt;Next Gen Events adds two new places where a change can happen in the store and your app never hears about it, both by design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Triggers.&lt;/strong&gt; An &lt;code&gt;update&lt;/code&gt; subscription only fires for the field paths you list. Leave one out and changes to that field produce nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query filters.&lt;/strong&gt; A filter evaluates the query result and suppresses the delivery when it is false. Filter on the wrong condition and you suppress the exact transition you needed to see.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both behave exactly as documented. Neither appears as a failure in your delivery logs, because from Shopify's side nothing failed. That is why the rest of this guide spends more time on what you will not receive than on what you will.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three parts of a subscription: triggers, query and filter
&lt;/h2&gt;

&lt;p&gt;Every Events subscription answers three questions, and keeping them separate in your head prevents most mistakes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Triggers answer "which changes matter?"&lt;/strong&gt; They are dot-notation field paths such as &lt;code&gt;product.status&lt;/code&gt; or &lt;code&gt;product.variants.price&lt;/code&gt;. They narrow &lt;code&gt;update&lt;/code&gt; deliveries to specific field changes. Parent triggers ending in &lt;code&gt;*&lt;/code&gt;, such as &lt;code&gt;product.variants.*&lt;/code&gt;, also fire for everything nested beneath them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The query answers "what data do I need when it fires?"&lt;/strong&gt; It is a GraphQL Admin API operation that Shopify runs after a qualifying change. Its variables, such as &lt;code&gt;$productId&lt;/code&gt; and &lt;code&gt;$variantsId&lt;/code&gt;, are filled in from the change itself, and its result arrives in the delivery's &lt;code&gt;data&lt;/code&gt; field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The filter answers "which results should be delivered at all?"&lt;/strong&gt; A &lt;code&gt;query_filter&lt;/code&gt; is an expression evaluated against the query result. If it evaluates to false, the delivery is suppressed. It requires a query, and any field it references must be selected in that same subscription's query.&lt;/p&gt;

&lt;p&gt;Shopify's &lt;a href="https://shopify.dev/docs/apps/build/events/optimizing-your-subscriptions" rel="noopener noreferrer"&gt;optimisation guide&lt;/a&gt; puts the division of labour plainly: use triggers to detect changes and use the filter only to test a current business condition. Mixing those roles, for example by using a filter to approximate a trigger that does not exist, is where suppression bugs come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  A complete subscription, line by line
&lt;/h2&gt;

&lt;p&gt;The fastest way to understand the format is to read one real subscription end to end. This one watches price and compare-at-price changes on variants and sends back the data a pricing or feed sync would need.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[events]&lt;/span&gt;
&lt;span class="py"&gt;api_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2026-10"&lt;/span&gt;

&lt;span class="nn"&gt;[[events.subscription]]&lt;/span&gt;
&lt;span class="py"&gt;handle&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"variant-pricing"&lt;/span&gt;
&lt;span class="py"&gt;topic&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Product"&lt;/span&gt;
&lt;span class="py"&gt;actions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"update"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;triggers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="s"&gt;"product.variants.price"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s"&gt;"product.variants.compareAtPrice"&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/events/variant-pricing"&lt;/span&gt;
&lt;span class="py"&gt;query&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"""
query variantPricing($variantsId: ID!) {
  productVariant(id: $variantsId) {
    id
    sku
    price
    compareAtPrice
    product {
      id
      status
    }
  }
}
"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Going through it field by field, using the &lt;a href="https://shopify.dev/docs/apps/build/cli-for-apps/app-configuration" rel="noopener noreferrer"&gt;app configuration reference&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;api_version&lt;/code&gt; under &lt;code&gt;[events]&lt;/code&gt; is required. It sets the version used to validate and run every subscription query. Developer preview users need to change this from &lt;code&gt;unstable&lt;/code&gt; to &lt;code&gt;2026-10&lt;/code&gt;, including any subscription-level overrides.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;handle&lt;/code&gt; is required and must be unique, using letters, numbers, underscores or hyphens, up to 50 characters. It comes back in both the payload and the headers, which makes it the natural routing key in your handler.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;topic&lt;/code&gt; is the resource, here &lt;code&gt;Product&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;actions&lt;/code&gt; is any combination of &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt; and &lt;code&gt;delete&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;uri&lt;/code&gt; can be an HTTPS URL, a relative path on your app's domain, or a Google Cloud Pub/Sub or Amazon EventBridge destination.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;triggers&lt;/code&gt; is optional, but without it an &lt;code&gt;update&lt;/code&gt; subscription fires for every supported field change on the resource. That is classic webhook behaviour with extra steps.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;query&lt;/code&gt; and &lt;code&gt;query_filter&lt;/code&gt; are optional.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice what the query asks for. It goes straight to the changed variant using &lt;code&gt;$variantsId&lt;/code&gt; rather than loading the product and paging through &lt;code&gt;variants(first: 100)&lt;/code&gt;. That choice matters for both correctness and the complexity limit, and both come up again below.&lt;/p&gt;

&lt;p&gt;A note on tooling versions: at the time of writing, the GA announcement and the migration guide quote different minimum Shopify CLI versions. Run &lt;code&gt;shopify version&lt;/code&gt;, then check the current requirement in the &lt;a href="https://shopify.dev/docs/apps/build/events/get-started" rel="noopener noreferrer"&gt;getting started guide&lt;/a&gt; on the day you deploy rather than trusting either number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a delivery: fields_changed, query_variables and data
&lt;/h2&gt;

&lt;p&gt;A delivery tells you what changed and what is true now, and those are deliberately separate fields.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://shopify.dev/docs/apps/build/events/delivery-structure" rel="noopener noreferrer"&gt;delivery structure guide&lt;/a&gt; lists the payload as &lt;code&gt;topic&lt;/code&gt;, &lt;code&gt;action&lt;/code&gt;, &lt;code&gt;handle&lt;/code&gt;, &lt;code&gt;fields_changed&lt;/code&gt;, &lt;code&gt;query_variables&lt;/code&gt;, and, when you configured a query, &lt;code&gt;data&lt;/code&gt; or &lt;code&gt;errors&lt;/code&gt;. A trimmed delivery for the subscription above 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;"topic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Product"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"update"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"handle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"variant-pricing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fields_changed"&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;"added"&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;"updated"&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="s2"&gt;"product[id: 'gid://shopify/Product/123'].variants[id: 'gid://shopify/ProductVariant/456'].price"&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;"removed"&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="nl"&gt;"query_variables"&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;"productId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gid://shopify/Product/123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"variantsId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gid://shopify/ProductVariant/456"&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;"data"&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;"productVariant"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gid://shopify/ProductVariant/456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TEE-BLK-M"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"price"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"24.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"compareAtPrice"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"30.00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"product"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gid://shopify/Product/123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ACTIVE"&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;Three details in that payload change how a handler should be written.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;fields_changed&lt;/code&gt; always contains &lt;code&gt;added&lt;/code&gt;, &lt;code&gt;updated&lt;/code&gt; and &lt;code&gt;removed&lt;/code&gt; arrays, even when they are empty. Each entry is a path with the IDs embedded. Adding or removing a variant arrives as &lt;code&gt;action: "update"&lt;/code&gt; with the path in &lt;code&gt;added&lt;/code&gt; or &lt;code&gt;removed&lt;/code&gt;, not as a separate create or delete on the variant.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;query_variables&lt;/code&gt; is a flat map of the IDs that were available for this change. IDs bubble up the ownership hierarchy, so a variant change also supplies &lt;code&gt;productId&lt;/code&gt;. On a &lt;code&gt;delete&lt;/code&gt;, this map is often the only reliable way to identify the resource, because a query for a deleted node may return &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;data&lt;/code&gt; reflects the state when the query ran, not a snapshot of the moment the change happened. Shopify's migration guide gives returns as an example where the queried status can differ from the state at the time of the event. Treat &lt;code&gt;fields_changed&lt;/code&gt; as "what happened" and &lt;code&gt;data&lt;/code&gt; as "what is true now", and do not assume the two describe the same instant.&lt;/p&gt;

&lt;p&gt;There is also an &lt;code&gt;errors&lt;/code&gt; field, present only when the query fails to execute. Queries run with your app's access scopes, and fields the app cannot read may come back as &lt;code&gt;null&lt;/code&gt; rather than raising an error. A handler that checks &lt;code&gt;errors&lt;/code&gt; but not unexpected nulls will miss scope problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  Triggers decide what you hear about
&lt;/h2&gt;

&lt;p&gt;The trigger list is where most coverage gaps start, and the rules are stricter than they look.&lt;/p&gt;

&lt;p&gt;Start with the obvious one: only listed paths fire. The &lt;a href="https://shopify.dev/docs/api/events/latest/product" rel="noopener noreferrer"&gt;Product topic reference&lt;/a&gt; lists more than fifty triggers, from &lt;code&gt;product.title&lt;/code&gt; and &lt;code&gt;product.status&lt;/code&gt; through variant prices, barcodes, options, swatches, media, bundle components and metafields. If your handler used to react to any product update and you now list three triggers, everything outside those three has gone quiet.&lt;/p&gt;

&lt;p&gt;The second rule is less obvious. Every query variable must be available for every trigger in the subscription. Product-level triggers such as &lt;code&gt;product.title&lt;/code&gt; supply only &lt;code&gt;productId&lt;/code&gt;. Variant triggers such as &lt;code&gt;product.variants.price&lt;/code&gt; supply &lt;code&gt;productId&lt;/code&gt; and &lt;code&gt;variantsId&lt;/code&gt;. A query that needs &lt;code&gt;$variantsId&lt;/code&gt; therefore cannot share a subscription with &lt;code&gt;product.title&lt;/code&gt;. Shopify validates this at save time, so the error is visible, but the fix (splitting into two subscriptions) changes how many deliveries you receive and how your handler routes them.&lt;/p&gt;

&lt;p&gt;Third, watch for overlapping prefixes. A parent trigger like &lt;code&gt;product.variants.*&lt;/code&gt; already includes &lt;code&gt;product.variants.price&lt;/code&gt;. Listing both in one subscription adds nothing. Listing them in two subscriptions means a single price change produces two deliveries, each with its own ID.&lt;/p&gt;

&lt;p&gt;Fourth, some things you might expect to trigger on do not exist. Calculated fields such as &lt;code&gt;product.description&lt;/code&gt; and auto-updated timestamps such as &lt;code&gt;product.updatedAt&lt;/code&gt; are not valid triggers. There is also no inventory quantity trigger on Product at all. Stock changes live on the InventoryItem topic, through its inventory-level triggers. An app that synced stock from &lt;code&gt;products/update&lt;/code&gt; needs a new subscription on a different topic, not a new trigger on the old one.&lt;/p&gt;

&lt;p&gt;The working rule we use: for every field your current handler reads, write down the trigger that will fire when it changes. If you cannot name one, that field is not covered yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filters decide what you never hear about
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;query_filter&lt;/code&gt; is the single easiest way to lose data in this migration, because it suppresses deliveries on purpose.&lt;/p&gt;

&lt;p&gt;The syntax is straightforward. Shopify's example is &lt;code&gt;product.status:'ACTIVE'&lt;/code&gt;, which only delivers when the queried product is active. The filter runs after the query, needs the referenced fields to be selected in that query, and returns nothing to your app when it evaluates to false.&lt;/p&gt;

&lt;p&gt;Now take an app that keeps a feed of active products. Filtering on active status looks like a sensible way to cut noise. Follow what happens when a merchant archives a product:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The product's status changes from &lt;code&gt;ACTIVE&lt;/code&gt; to &lt;code&gt;ARCHIVED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;product.status&lt;/code&gt; trigger fires.&lt;/li&gt;
&lt;li&gt;The query runs and returns &lt;code&gt;status: ARCHIVED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The filter evaluates to false, so the delivery is suppressed.&lt;/li&gt;
&lt;li&gt;Your feed never learns the product left, and keeps advertising it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Shopify's optimisation guide calls out this exact case: a filter on active status would suppress the deactivation delivery. The general rule is to test both directions of every state change your app tracks. If a transition out of the filtered state matters, either drop the filter or handle that transition in a separate, unfiltered subscription.&lt;/p&gt;

&lt;p&gt;Two related traps from the migration guide are worth knowing before you write any filter. Selecting a status field in a query does not filter anything on its own, so an &lt;code&gt;orders/paid&lt;/code&gt; replacement needs triggers and a filter, not just &lt;code&gt;displayFinancialStatus&lt;/code&gt; in the query. And the customer enable and disable cases return current state, so selecting &lt;code&gt;state&lt;/code&gt; will not restrict deliveries to that transition.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 100-point complexity limit
&lt;/h2&gt;

&lt;p&gt;Every Events subscription query is capped at 100 complexity points, and Shopify checks this at deploy time without running the query.&lt;/p&gt;

&lt;p&gt;The default costs come from the Admin API's &lt;a href="https://shopify.dev/docs/apps/build/apis/graphql-admin/rate-limits#cost-calculation" rel="noopener noreferrer"&gt;cost calculation rules&lt;/a&gt;: scalar and enum fields cost nothing, object fields cost one point, and connections cost according to their &lt;code&gt;first&lt;/code&gt; or &lt;code&gt;last&lt;/code&gt; argument. Some fields carry custom costs, so counting fields by hand only gives you an estimate. A broad query that loads an order with its line items, fulfilments and transactions, or a product with all variants and media, can pass 100 quickly. The failure message reports the calculated score against the maximum, so it is clear when it happens.&lt;/p&gt;

&lt;p&gt;Three habits keep queries under the limit and make them more correct at the same time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Query the changed node directly.&lt;/strong&gt; If the trigger supplies &lt;code&gt;variantsId&lt;/code&gt;, query &lt;code&gt;productVariant(id: $variantsId)&lt;/code&gt; rather than the product's variant connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat &lt;code&gt;first: N&lt;/code&gt; as partial coverage.&lt;/strong&gt; A connection like &lt;code&gt;variants(first: 50)&lt;/code&gt; can leave out the very variant that changed if it falls outside that page. A page size limits what comes back. It is not a complexity budget and it is not a complete snapshot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split rather than widen.&lt;/strong&gt; Two small subscriptions with distinct handles are usually better than one large one. Each delivery gets smaller, and you stop reloading data that did not change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Validate before release with &lt;code&gt;shopify app deploy --no-release&lt;/code&gt;. It checks every Events query against the limit and creates an app version without releasing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying and deduplicating every delivery
&lt;/h2&gt;

&lt;p&gt;The delivery mechanics will feel familiar if you have built classic webhook handlers, and the same discipline applies. If you want the wider context on endpoint hardening, our post on &lt;a href="https://zignites.com/blog/api-security-owasp-top-10" rel="noopener noreferrer"&gt;API security and the OWASP API Top 10&lt;/a&gt; covers the general rules.&lt;/p&gt;

&lt;p&gt;For HTTPS deliveries, the &lt;a href="https://shopify.dev/docs/apps/build/events/verify-deliveries" rel="noopener noreferrer"&gt;verification guide&lt;/a&gt; gives the requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each request carries a base64-encoded &lt;code&gt;Shopify-Hmac-Sha256&lt;/code&gt; header, computed from the raw request body with your app's client secret. Verify it before processing. Pub/Sub and EventBridge deliveries do not need HMAC verification.&lt;/li&gt;
&lt;li&gt;After rotating the client secret, the signature can take up to an hour to reflect the new secret, so accept both during a rotation window.&lt;/li&gt;
&lt;li&gt;Duplicates can happen, for example after a timeout and retry. Prefer idempotent processing. Where that is not possible, store each &lt;code&gt;Shopify-Webhook-Id&lt;/code&gt; and skip any you have already seen, returning success.&lt;/li&gt;
&lt;li&gt;If several subscriptions match the same change, each gets its own delivery with its own &lt;code&gt;Shopify-Webhook-Id&lt;/code&gt;. Deduplicating by that header will not merge them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://shopify.dev/docs/apps/build/events/troubleshoot" rel="noopener noreferrer"&gt;troubleshooting guide&lt;/a&gt; adds the timing rules. Your endpoint has five seconds to respond, any 2xx status counts as success, and Shopify retries failures automatically. The guide does not publish a retry count or window. It does say that if an endpoint keeps failing, deliveries for that subscription stop and you may need to reconcile missing data.&lt;/p&gt;

&lt;p&gt;Five seconds is not long enough to call a third-party API, write to several tables and send an email. Verify, record, queue, respond. Here is that shape in Node with Express:&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;crypto&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;node:crypto&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;express&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;express&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;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&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;SHOPIFY_API_SECRET&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;validHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;header&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;header&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;false&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;expected&lt;/span&gt; &lt;span class="o"&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;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="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;rawBody&lt;/span&gt;&lt;span class="p"&gt;)&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="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;header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64&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="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;expected&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;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;expected&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;app&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;/events/:route&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&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;application/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;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="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="nf"&gt;validHmac&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;body&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="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;Shopify-Hmac-Sha256&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&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;deliveryId&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="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;Shopify-Webhook-Id&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;isNew&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;deliveries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insertIfAbsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deliveryId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;shop&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="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;Shopify-Shop-Domain&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;handle&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="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;Shopify-Handle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;triggeredAt&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="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;Shopify-Triggered-At&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="nx"&gt;isNew&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shopify-events&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;deliveryId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;body&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;deliveries.insertIfAbsent&lt;/code&gt; should be a single atomic operation, such as an insert against a unique index, so two copies of the same delivery arriving together cannot both pass. The worker that consumes the queue does the real processing and should itself be idempotent, because a crash between processing and acknowledging will replay a message. If you build on Shopify's React Router app template, &lt;code&gt;authenticate.webhook(request)&lt;/code&gt; handles the HMAC check for you before your code runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ordering, delays and why you still need reconciliation
&lt;/h2&gt;

&lt;p&gt;Next Gen Events does not promise ordered or timely delivery, and an app that assumes either will drift.&lt;/p&gt;

&lt;p&gt;The documentation does not state an ordering guarantee. The optimisation guide says targeted subscriptions can fail or arrive out of order, and the troubleshooting guide says deliveries can arrive late without giving a maximum delay. Two consequences follow.&lt;/p&gt;

&lt;p&gt;Do not let a late delivery overwrite newer state. Because &lt;code&gt;data&lt;/code&gt; reflects the query result rather than the original change, it is usually current. But if you store values derived from it, compare a source timestamp such as the resource's &lt;code&gt;updatedAt&lt;/code&gt; before writing, and discard anything older than what you already hold. &lt;code&gt;Shopify-Triggered-At&lt;/code&gt; tells you when Shopify triggered the delivery, which is useful for spotting stale ones.&lt;/p&gt;

&lt;p&gt;Run a reconciliation job. Shopify's own guidance is to keep a separate process that uses targeted Admin API queries or bulk operations to repair missed work and page through complete collections. That covers the delivery that exhausted its retries during an outage, the transition a filter suppressed, and the trigger you forgot. It is the safety net that makes a targeted event system safe to depend on, and it is the same pattern we recommend in any &lt;a href="https://zignites.com/blog/multi-tenant-saas-architecture" rel="noopener noreferrer"&gt;multi-tenant SaaS architecture&lt;/a&gt; where events feed per-tenant state.&lt;/p&gt;

&lt;p&gt;How often to reconcile depends on how costly drift is. A nightly bulk comparison is enough for most catalogue syncs. Inventory and order state usually justify something tighter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Large payloads and payload_url
&lt;/h2&gt;

&lt;p&gt;When a delivery exceeds the channel's size limit, Shopify sends a pointer instead of the payload, and a handler that does not check for it will read empty fields.&lt;/p&gt;

&lt;p&gt;The limits are 5 MB for HTTPS, 10 MB for Google Cloud Pub/Sub and 256 KB for Amazon EventBridge. Over the limit, Shopify stores the full content and delivers a small payload with &lt;code&gt;topic&lt;/code&gt;, &lt;code&gt;action&lt;/code&gt;, &lt;code&gt;handle&lt;/code&gt;, &lt;code&gt;payload_url&lt;/code&gt;, &lt;code&gt;payload_size_bytes&lt;/code&gt; and &lt;code&gt;expires_at&lt;/code&gt;. The &lt;code&gt;fields_changed&lt;/code&gt;, &lt;code&gt;query_variables&lt;/code&gt; and &lt;code&gt;data&lt;/code&gt; fields only exist in the stored content.&lt;/p&gt;

&lt;p&gt;So the first thing a worker should do is check for &lt;code&gt;payload_url&lt;/code&gt;. If it is present, download the content before &lt;code&gt;expires_at&lt;/code&gt;, because the URL is short-lived. For HTTPS, verify the HMAC on the small payload you received, not on the downloaded content. EventBridge users should take the 256 KB ceiling seriously, since a modest product query with metafields can approach it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mapping classic webhook topics: the gotchas worth knowing first
&lt;/h2&gt;

&lt;p&gt;Most classic topics map cleanly onto an Events topic plus a trigger. A handful do not, and Shopify's &lt;a href="https://shopify.dev/docs/apps/build/events/migrate-from-webhooks" rel="noopener noreferrer"&gt;migration guide&lt;/a&gt; lists them. These are the ones most likely to affect a typical app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Field names change shape.&lt;/strong&gt; REST payload fields generally move from snake_case to camelCase, but not always one to one. &lt;code&gt;admin_graphql_api_id&lt;/code&gt; becomes &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;body_html&lt;/code&gt; becomes &lt;code&gt;descriptionHtml&lt;/code&gt;, and &lt;code&gt;financial_status&lt;/code&gt; becomes &lt;code&gt;displayFinancialStatus&lt;/code&gt;. Some REST fields have no GraphQL equivalent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tags are the full current set.&lt;/strong&gt; On Events, &lt;code&gt;tags&lt;/code&gt; is every tag the resource has now, not the ones added or removed. If your logic reacts to a specific tag being added, compare against stored state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Order status webhooks need triggers and filters.&lt;/strong&gt; &lt;code&gt;orders/paid&lt;/code&gt;, &lt;code&gt;orders/fulfilled&lt;/code&gt; and &lt;code&gt;orders/partially_fulfilled&lt;/code&gt; do not have one-line equivalents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inventory lives on InventoryItem.&lt;/strong&gt; There is no stock quantity trigger on Product. For a location being disconnected, subscribe to &lt;code&gt;inventoryItem.inventoryLevel.isActive&lt;/code&gt; and check for false.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fulfilment IDs differ.&lt;/strong&gt; A classic fulfilment ID is not a fulfilment order ID. Query the parent through &lt;code&gt;fulfillmentOrderId&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Returns need care.&lt;/strong&gt; A &lt;code&gt;create&lt;/code&gt; delivery cannot reliably tell you whether a return was requested or already approved. Use the &lt;code&gt;return.requestApprovedAt&lt;/code&gt; trigger for approvals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some topics stay on webhooks.&lt;/strong&gt; In 2026-10 there is no Events equivalent for the reverse fulfilment order disposition webhook, so keep &lt;code&gt;reverse_fulfillment_orders/dispose&lt;/code&gt; where it is.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deletes are the simplest case. No query is needed, and &lt;code&gt;query_variables&lt;/code&gt; identifies the deleted resource.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which workflows to move first
&lt;/h2&gt;

&lt;p&gt;Move the workflows where an Events payload removes a follow-up API call and where a missed delivery is cheap to repair. Leave the ones where a missed delivery costs money until last.&lt;/p&gt;

&lt;p&gt;A practical order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;High-volume, read-heavy handlers.&lt;/strong&gt; A &lt;code&gt;products/update&lt;/code&gt; handler that immediately fetches the product to see what changed is the clearest win. Targeted triggers cut volume and the query removes the fetch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catalogue and content sync.&lt;/strong&gt; Product, collection, metafield, metaobject and page changes feeding a search index, a product feed or a headless front end. Our post on &lt;a href="https://zignites.com/blog/headless-cms-ai-search" rel="noopener noreferrer"&gt;structured content for AI search&lt;/a&gt; covers why keeping that layer accurate matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customer and company data.&lt;/strong&gt; Lower volume, but useful once the pattern is proven.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orders, refunds, returns and inventory last.&lt;/strong&gt; These have the most topic-specific gotchas and the highest cost when something is missed. Move them once reconciliation is running and you have a few weeks of data from the earlier workflows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Measure as you go. Shopify suggests tracking delivery volume, payload size and follow-up API calls during migration rather than assuming savings, and the GA post does not claim specific numbers. Optimised subscriptions can even produce more deliveries than before, because one merchant action may touch both product and variant data. Each delivery is smaller and cheaper, which is usually the better trade, but only measurement will tell you for your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running both systems side by side
&lt;/h2&gt;

&lt;p&gt;During migration, classic webhooks and Events subscriptions can live in the same app, and that overlap is what makes the move safe.&lt;/p&gt;

&lt;p&gt;The pattern is a shadow period. Deploy the Events subscription alongside the existing webhook, but have the new handler record what it would do rather than doing it. Compare the two streams for a few days across real merchant activity: every change the webhook caught should appear in an Events delivery, and every Events delivery should map to something the webhook path processed. Gaps point straight at a missing trigger or an over-eager filter.&lt;/p&gt;

&lt;p&gt;Two things to plan for during the overlap. The two systems produce separate deliveries with separate IDs, so &lt;code&gt;Shopify-Webhook-Id&lt;/code&gt; will not deduplicate across them. If both paths are live and writing, rely on idempotent writes keyed on the resource and its &lt;code&gt;updatedAt&lt;/code&gt;. And when you cut over, switch the write path to Events first, keep the webhook subscribed but inert for a short period, then remove it. Removing first leaves you with no way back.&lt;/p&gt;

&lt;h2&gt;
  
  
  A test plan before you release
&lt;/h2&gt;

&lt;p&gt;A subscription that deploys is validated. A subscription that works has been tested against every transition your app depends on. Before releasing, we run through this on a development store:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validate with &lt;code&gt;shopify app deploy --no-release&lt;/code&gt; and fix any complexity or variable errors.&lt;/li&gt;
&lt;li&gt;Confirm every trigger exists in the Events reference for 2026-10 and supplies every variable the query uses.&lt;/li&gt;
&lt;li&gt;Confirm the app has the access scopes each topic requires, and look for unexpected nulls in &lt;code&gt;data&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Exercise create, update and delete, and inspect &lt;code&gt;data&lt;/code&gt;, &lt;code&gt;errors&lt;/code&gt;, &lt;code&gt;fields_changed&lt;/code&gt; and &lt;code&gt;query_variables&lt;/code&gt; for each.&lt;/li&gt;
&lt;li&gt;Remove a relationship (a product from a collection, a variant from a product) and check the delivery arrives.&lt;/li&gt;
&lt;li&gt;For every filter, move the resource into and out of the filtered state, and confirm you see every transition you need.&lt;/li&gt;
&lt;li&gt;Send the same delivery twice and confirm nothing is processed twice.&lt;/li&gt;
&lt;li&gt;Hold a delivery past five seconds in a test and confirm the retry is handled cleanly.&lt;/li&gt;
&lt;li&gt;Run the reconciliation job and confirm it finds and repairs a change you deliberately suppressed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Dev Dashboard helps with the last mile. Under your app's Logs you can see deliveries from the past seven days, filtered by topic, status and shop, with response codes, attempt numbers and payload sizes. The Overview shows failed delivery rate and 90th-percentile response time. Log data can lag by several minutes, so do not panic if a test delivery takes a moment to appear.&lt;/p&gt;

&lt;p&gt;Every check above, along with the planning and cut-over steps, is in the printable worksheet below with pass criteria and a readiness score, so you can tick through it per workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the Next Gen Events Migration Worksheet
&lt;/h2&gt;

&lt;p&gt;A two-page printable worksheet with 22 checks across planning, building and cut-over, each with clear pass criteria, plus a readiness score that tells you whether a workflow is ready to move. No account, no sales sequence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://zignites.com/contact?resource=shopify-events-migration&amp;amp;from=shopify-next-gen-events-migration" rel="noopener noreferrer"&gt;Get the PDF&lt;/a&gt;&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Do I have to migrate from classic webhooks to Next Gen Events now?
&lt;/h3&gt;

&lt;p&gt;No. Classic webhooks keep working, and as of API version 2026-10 Shopify has not announced a sunset date. Both can run in the same app, so you can migrate one workflow at a time. The advantage of starting early is that you can test thoroughly without a deadline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which Shopify topics does Next Gen Events support?
&lt;/h3&gt;

&lt;p&gt;At GA there are 18 topics: Product, Collection, Customer, Company, Order, FulfillmentOrder, Refund, Return, InventoryItem, InventoryShipment, InventoryTransfer, Location, Article, Blog, Page, MetafieldDefinition, Metaobject and MetaobjectDefinition. Each has its own triggers, query variables and access scopes, listed in the Events API reference.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I avoid processing the same Shopify event twice?
&lt;/h3&gt;

&lt;p&gt;Make processing idempotent where you can, so receiving a delivery twice has the same effect as receiving it once. Otherwise, store each &lt;code&gt;Shopify-Webhook-Id&lt;/code&gt; atomically and skip any you have already seen, while still returning a 2xx response. Remember that separate subscriptions matching the same change produce separate IDs.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if my Events query is too complex?
&lt;/h3&gt;

&lt;p&gt;Deployment fails. Each subscription query is capped at 100 complexity points and checked at deploy time. The error reports the calculated score against the maximum. Query the changed node directly using the variable the trigger supplies, and split large subscriptions into smaller ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the data in a delivery show the state at the moment of the change?
&lt;/h3&gt;

&lt;p&gt;Not necessarily. The query runs after the change, so &lt;code&gt;data&lt;/code&gt; reflects state when the query ran. Use &lt;code&gt;fields_changed&lt;/code&gt; to understand what happened, &lt;code&gt;data&lt;/code&gt; for current state, and source timestamps such as &lt;code&gt;updatedAt&lt;/code&gt; to avoid overwriting newer information with a late delivery.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I send Events to Pub/Sub or EventBridge instead of an HTTPS endpoint?
&lt;/h3&gt;

&lt;p&gt;Yes. A subscription's &lt;code&gt;uri&lt;/code&gt; can point to an HTTPS endpoint, a relative path on your app, Google Cloud Pub/Sub or Amazon EventBridge. Payload limits differ by channel: 5 MB for HTTPS, 10 MB for Pub/Sub and 256 KB for EventBridge, with larger payloads delivered as a short-lived &lt;code&gt;payload_url&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://zignites.com/blog/shopify-next-gen-events-migration" rel="noopener noreferrer"&gt;zignites.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>webhooks</category>
      <category>graphql</category>
      <category>systemdesign</category>
    </item>
    <item>
      <title>Next.js 15 Support Ends Oct 21: The Upgrade Changes Your Build Won't Catch</title>
      <dc:creator>Muhammad Zeeshan Sardar</dc:creator>
      <pubDate>Wed, 14 Oct 2026 14:30:00 +0000</pubDate>
      <link>https://dev.to/zeeshansardar08/nextjs-15-support-ends-oct-21-the-upgrade-changes-your-build-wont-catch-38bj</link>
      <guid>https://dev.to/zeeshansardar08/nextjs-15-support-ends-oct-21-the-upgrade-changes-your-build-wont-catch-38bj</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Next.js 15 leaves its support window on 21 October 2026. Run the &lt;code&gt;upgrade&lt;/code&gt; codemod and the separate &lt;code&gt;next-async-request-api&lt;/code&gt; codemod, then test the things a build can't: image quality now defaults to 75 only, image cache TTL went from 60 seconds to 4 hours, &lt;code&gt;next build&lt;/code&gt; no longer lints, sitemap &lt;code&gt;id&lt;/code&gt; is a &lt;code&gt;Promise&amp;lt;string&amp;gt;&lt;/code&gt;, and &lt;code&gt;proxy.ts&lt;/code&gt; runs on Node.js only.&lt;/p&gt;

&lt;p&gt;Next.js 15 leaves its support window on 21 October 2026. From that date, the version most production Next.js apps still run on stops receiving the critical fixes and security updates it has been getting since Next.js 16 shipped a year ago.&lt;/p&gt;

&lt;p&gt;Most upgrade guides stop at "run the codemod and fix the type errors." That gets you a green build. It does not get you a finished upgrade, because several of the changes in Next.js 16 compile cleanly and then quietly change what your users see: image quality, how long images stay cached, whether your CI still lints, and how the page scrolls between routes.&lt;/p&gt;

&lt;p&gt;This guide is the checklist we work through on every Next.js upgrade. It is organised so you can run it top to bottom, and every section opens with the answer so you can skip to the parts that apply to your app. Version numbers are current as of 13 October 2026: Next.js 16.4.0 is the latest stable release, and the newest Next.js 15 backport is 15.5.27.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens to Next.js 15 on October 21, 2026
&lt;/h2&gt;

&lt;p&gt;Next.js 15 reaches the end of its Maintenance LTS period on 21 October 2026, two years after its release on 21 October 2024. After that date it falls outside the &lt;a href="https://nextjs.org/support-policy" rel="noopener noreferrer"&gt;Next.js support policy&lt;/a&gt;, which means no more scheduled critical bug fixes or security patches.&lt;/p&gt;

&lt;p&gt;Your app does not stop working on the 22nd. Nothing is switched off. What changes is the risk profile: the next vulnerability disclosed in the framework will be fixed in Next.js 16, and a Next.js 15 app will either need that upgrade done under pressure or stay exposed. The policy does allow for patching unsupported versions "in rare circumstances" for severe issues, but that is an exception, not a plan.&lt;/p&gt;

&lt;p&gt;The support model works like this. A major version stays in Active LTS, with features, fixes and security patches, until the next major ships. It then moves to Maintenance LTS, where it only receives critical bug fixes and essential security updates, for two years from its original release date. Next.js 16 has been Active LTS since 21 October 2025. Next.js 15 has been in maintenance since the same day, and its clock runs out next week.&lt;/p&gt;

&lt;p&gt;If you are still on Next.js 14, you are already a year past its end of life, which was 26 October 2025. The same upgrade path applies, with the Next.js 15 breaking changes stacked on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a green build is not a finished upgrade
&lt;/h2&gt;

&lt;p&gt;A passing &lt;code&gt;next build&lt;/code&gt; proves your code compiles under the new rules. It says nothing about whether the app behaves the way it did before, and in Next.js 16 several changes are deliberately silent.&lt;/p&gt;

&lt;p&gt;The official upgrade guide says as much in its own migration checklist: a green build alone does not close runtime findings. Here are the changes that fit that description, each covered in detail below:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Image requests for a quality other than 75 are now quietly coerced to the nearest allowed value, so images can look different without any error in the build.&lt;/li&gt;
&lt;li&gt;Optimised images without cache headers now stay cached for four hours instead of sixty seconds, so an image replaced in your CMS can keep serving the old version all afternoon.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;next build&lt;/code&gt; no longer runs ESLint, so a pipeline that relied on the build to catch lint errors now ships them.&lt;/li&gt;
&lt;li&gt;Next.js no longer overrides a global &lt;code&gt;scroll-behavior: smooth&lt;/code&gt; during route changes, so navigations that used to jump to the top now animate.&lt;/li&gt;
&lt;li&gt;A sitemap &lt;code&gt;id&lt;/code&gt; is now a promise of a string, and plain JavaScript will not warn you when arithmetic on it produces the wrong number.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these is a reasonable default on its own. Together they are why the testing section near the end of this guide matters more than the codemod at the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check Node.js, TypeScript and browser requirements first
&lt;/h2&gt;

&lt;p&gt;Next.js 16 requires Node.js 20.9.0 or newer, TypeScript 5.1.0 or newer, and drops support for older browsers. Check these before touching any code, because a mismatch here produces confusing errors later.&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;Next.js 16 minimum&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Node.js&lt;/td&gt;
&lt;td&gt;20.9.0 (Node.js 18 is no longer supported)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;td&gt;5.1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browsers&lt;/td&gt;
&lt;td&gt;Chrome 111+, Edge 111+, Firefox 111+, Safari 16.4+&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three places tend to get missed. The first is the Node.js version in your hosting configuration, which is separate from the one on your laptop. The second is the Node.js image in your CI workflow and any Dockerfile. The third is your browser support promise: if a client contract or analytics data says you support older Safari versions, that conversation needs to happen before the upgrade, not after.&lt;/p&gt;

&lt;p&gt;Update &lt;code&gt;@types/react&lt;/code&gt; and &lt;code&gt;@types/react-dom&lt;/code&gt; alongside &lt;code&gt;react&lt;/code&gt; and &lt;code&gt;react-dom&lt;/code&gt;. The App Router in Next.js 16 runs on the React canary channel that includes React 19.2 features such as View Transitions, &lt;code&gt;useEffectEvent&lt;/code&gt; and Activity, and stale type packages produce misleading errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run the upgrade codemod, then run the one it skips
&lt;/h2&gt;

&lt;p&gt;Start on a dedicated branch and run the official upgrade codemod:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;According to the &lt;a href="https://nextjs.org/docs/app/guides/upgrading/codemods" rel="noopener noreferrer"&gt;codemod documentation&lt;/a&gt;, this updates &lt;code&gt;next.config.js&lt;/code&gt; for the new top-level &lt;code&gt;turbopack&lt;/code&gt; option, migrates &lt;code&gt;next lint&lt;/code&gt; to the ESLint CLI, renames the &lt;code&gt;middleware&lt;/code&gt; convention to &lt;code&gt;proxy&lt;/code&gt;, removes the &lt;code&gt;unstable_&lt;/code&gt; prefix from stabilised APIs, and removes the &lt;code&gt;experimental_ppr&lt;/code&gt; route segment config.&lt;/p&gt;

&lt;p&gt;What it does not do is migrate synchronous request APIs. If your app still reads &lt;code&gt;params&lt;/code&gt;, &lt;code&gt;searchParams&lt;/code&gt;, &lt;code&gt;cookies()&lt;/code&gt;, &lt;code&gt;headers()&lt;/code&gt; or &lt;code&gt;draftMode()&lt;/code&gt; synchronously, which Next.js 15 allowed with a warning during its compatibility period, run the second codemod as well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @next/codemod@canary next-async-request-api &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the single most common reason an upgrade that "ran the codemod" still fails, so it is worth running even if you believe your code is already async.&lt;/p&gt;

&lt;p&gt;If you work with a coding agent, the guide now recommends &lt;code&gt;npx next@canary upgrade --agent=latest&lt;/code&gt;, which prepares the migration guides and codemod commands for the agent to apply and verify. The checklist below still applies, because an agent can also produce a green build that behaves differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Async request APIs: synchronous access is gone
&lt;/h2&gt;

&lt;p&gt;In Next.js 16, &lt;code&gt;cookies&lt;/code&gt;, &lt;code&gt;headers&lt;/code&gt;, &lt;code&gt;draftMode&lt;/code&gt;, &lt;code&gt;params&lt;/code&gt; and &lt;code&gt;searchParams&lt;/code&gt; can only be read asynchronously. Next.js 15 introduced the async versions with a temporary synchronous fallback, and that fallback has been removed.&lt;/p&gt;

&lt;p&gt;The change covers &lt;code&gt;params&lt;/code&gt; in &lt;code&gt;layout&lt;/code&gt;, &lt;code&gt;page&lt;/code&gt;, &lt;code&gt;route&lt;/code&gt; and &lt;code&gt;default&lt;/code&gt; files, in &lt;code&gt;opengraph-image&lt;/code&gt;, &lt;code&gt;twitter-image&lt;/code&gt;, &lt;code&gt;icon&lt;/code&gt; and &lt;code&gt;apple-icon&lt;/code&gt;, and &lt;code&gt;searchParams&lt;/code&gt; in &lt;code&gt;page&lt;/code&gt; files.&lt;/p&gt;

&lt;p&gt;The cleanest way to get the types right is the generated helpers. Run &lt;code&gt;npx next typegen&lt;/code&gt; and you get globally available &lt;code&gt;PageProps&lt;/code&gt;, &lt;code&gt;LayoutProps&lt;/code&gt; and &lt;code&gt;RouteContext&lt;/code&gt; types that know your route parameters:&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/blog/[slug]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PageProps&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;/blog/[slug]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&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;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&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;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Blog Post: &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;slug&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;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the codemod runs, search the codebase for &lt;code&gt;UnsafeUnwrapped&lt;/code&gt; and for comments the codemod left where it could not migrate safely. Those markers are easy to commit by accident and they are exactly the places the upgrade guide tells you to resolve. In Client Components that need a value synchronously, React's &lt;code&gt;use&lt;/code&gt; hook is the documented route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metadata images and sitemaps now receive promises
&lt;/h2&gt;

&lt;p&gt;The image generation functions behind &lt;code&gt;opengraph-image&lt;/code&gt;, &lt;code&gt;twitter-image&lt;/code&gt;, &lt;code&gt;icon&lt;/code&gt; and &lt;code&gt;apple-icon&lt;/code&gt; now receive &lt;code&gt;params&lt;/code&gt; and &lt;code&gt;id&lt;/code&gt; as promises, and so does the &lt;code&gt;sitemap&lt;/code&gt; function when you use &lt;code&gt;generateSitemaps&lt;/code&gt;. The &lt;code&gt;generateImageMetadata&lt;/code&gt; and &lt;code&gt;generateSitemaps&lt;/code&gt; functions themselves still receive synchronous values.&lt;/p&gt;

&lt;p&gt;For Open Graph images, the fix is mechanical:&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;// app/shop/[slug]/opengraph-image.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;imageId&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;id&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;Sitemaps deserve more care. In Next.js 15 the &lt;code&gt;id&lt;/code&gt; passed to &lt;code&gt;sitemap&lt;/code&gt; was the number you returned from &lt;code&gt;generateSitemaps&lt;/code&gt;. In Next.js 16 it is a promise that resolves to a string. TypeScript will flag the missing &lt;code&gt;await&lt;/code&gt;, but in a plain JavaScript project nothing stops &lt;code&gt;id * 50000&lt;/code&gt; from quietly evaluating to &lt;code&gt;NaN&lt;/code&gt;, and nothing stops &lt;code&gt;resolvedId + 1&lt;/code&gt; from producing &lt;code&gt;"11"&lt;/code&gt; instead of &lt;code&gt;2&lt;/code&gt;. Either one gives you a sitemap that builds, deploys and lists the wrong URLs.&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;// app/product/sitemap.js&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sitemap&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolvedId&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;id&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&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;resolvedId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;50000&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;Convert to a number explicitly, then open the generated sitemap files in a browser and check the first and last URL in each one. Sitemaps are not something anyone looks at after launch, which is why a broken one can go unnoticed until Search Console starts reporting coverage drops.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turbopack is the default bundler, and custom webpack config fails the build
&lt;/h2&gt;

&lt;p&gt;Turbopack is now stable and used by default for both &lt;code&gt;next dev&lt;/code&gt; and &lt;code&gt;next build&lt;/code&gt;. If your project has a custom &lt;code&gt;webpack&lt;/code&gt; configuration, &lt;code&gt;next build&lt;/code&gt; will fail on purpose rather than silently ignore it.&lt;/p&gt;

&lt;p&gt;You can remove the &lt;code&gt;--turbopack&lt;/code&gt; and &lt;code&gt;--turbo&lt;/code&gt; flags from your scripts. If the build fails because a webpack config was found, you have three options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Migrate the configuration to Turbopack equivalents, which is the long-term answer.&lt;/li&gt;
&lt;li&gt;Keep webpack for production builds with &lt;code&gt;next build --webpack&lt;/code&gt; while you migrate.&lt;/li&gt;
&lt;li&gt;Build with Turbopack and ignore the webpack config, but only if you are sure nothing in it matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you never wrote a webpack config yourself and still see this error, a plugin added one. Bundle analysers, MDX setups, SVG loaders and some monitoring SDKs are common sources.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;experimental.turbopack&lt;/code&gt; block has moved to a top-level &lt;code&gt;turbopack&lt;/code&gt; key in &lt;code&gt;next.config.ts&lt;/code&gt;. Two smaller changes catch people out. Turbopack does not support the legacy tilde prefix in Sass imports, so &lt;code&gt;@import '~bootstrap/...'&lt;/code&gt; becomes &lt;code&gt;@import 'bootstrap/...'&lt;/code&gt;. And code that relied on webpack's &lt;code&gt;resolve.fallback&lt;/code&gt; to silence errors about Node.js modules like &lt;code&gt;fs&lt;/code&gt; in client bundles needs &lt;code&gt;turbopack.resolveAlias&lt;/code&gt; instead, although the better fix is to stop importing server modules from client code.&lt;/p&gt;

&lt;h2&gt;
  
  
  middleware.ts becomes proxy.ts, unless you run on the edge
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;middleware&lt;/code&gt; file convention is deprecated and renamed to &lt;code&gt;proxy&lt;/code&gt;, along with the exported function name and any config flags containing the word, such as &lt;code&gt;skipMiddlewareUrlNormalize&lt;/code&gt;, which becomes &lt;code&gt;skipProxyUrlNormalize&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="nb"&gt;mv &lt;/span&gt;middleware.ts proxy.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// proxy.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&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 important detail is the runtime. &lt;code&gt;proxy&lt;/code&gt; runs on Node.js, and that cannot be configured. The edge runtime is not supported in &lt;code&gt;proxy&lt;/code&gt;. If your middleware currently runs on the edge, for geolocation, low-latency redirects or A/B routing at the network edge, the guidance is to keep using &lt;code&gt;middleware&lt;/code&gt; for now rather than renaming it.&lt;/p&gt;

&lt;p&gt;The upgrade codemod handles the rename. After it runs, check whether any of your middleware declared the edge runtime, and if it did, decide deliberately whether to move it to Node.js or keep the old convention. The &lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/proxy" rel="noopener noreferrer"&gt;proxy documentation&lt;/a&gt; is the reference to read before choosing.&lt;/p&gt;

&lt;h2&gt;
  
  
  next/image defaults changed in six ways
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;next/image&lt;/code&gt; has six breaking changes in Next.js 16, and most of them change behaviour without producing an error. This is the section where we spend the most testing time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quality values.&lt;/strong&gt; &lt;code&gt;images.qualities&lt;/code&gt; now defaults to &lt;code&gt;[75]&lt;/code&gt; only. A &lt;code&gt;quality={90}&lt;/code&gt; prop is quietly coerced to the closest allowed value, so it renders at 75. Direct requests to &lt;code&gt;/_next/image&lt;/code&gt; with an unsupported &lt;code&gt;q&lt;/code&gt; value return a 400. If your design depends on higher-quality hero or product images, list the values you use:&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;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;qualities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;90&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;strong&gt;Cache lifetime.&lt;/strong&gt; &lt;code&gt;images.minimumCacheTTL&lt;/code&gt; now defaults to 4 hours (14,400 seconds) instead of 60 seconds. This reduces CPU cost when upstream images lack cache headers, which is a sensible default. The side effect is that an image replaced at the same URL in a headless CMS can keep serving the old version for hours. If editors expect instant changes, either set a lower value or change the image URL on update.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image sizes.&lt;/strong&gt; &lt;code&gt;16&lt;/code&gt; has been removed from the default &lt;code&gt;images.imageSizes&lt;/code&gt; array, which shortens the &lt;code&gt;srcset&lt;/code&gt;. Add it back only if you genuinely serve 16-pixel images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local images with query strings.&lt;/strong&gt; A local source like &lt;code&gt;/assets/photo?v=1&lt;/code&gt; now requires a matching &lt;code&gt;images.localPatterns&lt;/code&gt; entry with a &lt;code&gt;search&lt;/code&gt; value. This prevents enumeration attacks, and it also breaks cache-busting query strings that used to work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Local IP addresses.&lt;/strong&gt; Optimising images from local IP addresses is now blocked by default. Hosting inside a VPC with split-horizon DNS is the usual way to hit this, and it shows up as a 400. &lt;code&gt;images.dangerouslyAllowLocalIP&lt;/code&gt; exists for private networks, and the name tells you how carefully to use it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redirects.&lt;/strong&gt; &lt;code&gt;images.maximumRedirects&lt;/code&gt; now defaults to 3 rather than unlimited. Image CDNs that chain redirects can start failing.&lt;/p&gt;

&lt;p&gt;Two deprecations belong on the same list. &lt;code&gt;next/legacy/image&lt;/code&gt; is deprecated in favour of &lt;code&gt;next/image&lt;/code&gt;, and &lt;code&gt;images.domains&lt;/code&gt; is deprecated in favour of &lt;code&gt;images.remotePatterns&lt;/code&gt;, which is stricter about protocol and hostname. The full option list is in the &lt;a href="https://nextjs.org/docs/app/api-reference/components/image" rel="noopener noreferrer"&gt;image configuration reference&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caching APIs: revalidateTag needs a profile, and updateTag is new
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;revalidateTag&lt;/code&gt; now takes a second argument naming a &lt;code&gt;cacheLife&lt;/code&gt; profile. The single-argument form is deprecated and produces a TypeScript error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&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;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// After&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;posts&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The more interesting change is what the second argument forces you to decide. &lt;code&gt;revalidateTag&lt;/code&gt; gives stale-while-revalidate behaviour: readers see the old content while fresh data loads in the background. That is right for blog posts, product catalogues and documentation.&lt;/p&gt;

&lt;p&gt;For changes a user makes and expects to see immediately, Next.js 16 adds &lt;a href="https://nextjs.org/docs/app/api-reference/functions/updateTag" rel="noopener noreferrer"&gt;&lt;code&gt;updateTag&lt;/code&gt;&lt;/a&gt;, which is only available in Server Actions and gives read-your-writes semantics by expiring and refreshing the data within the same request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="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;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;updateUserProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Profile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&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;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;updateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`user-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also a new &lt;code&gt;refresh()&lt;/code&gt; for refreshing the client router from inside a Server Action, which suits cases like updating a notification count after marking something as read. &lt;code&gt;cacheLife&lt;/code&gt; and &lt;code&gt;cacheTag&lt;/code&gt; are stable, so imports aliased from &lt;code&gt;unstable_cacheLife&lt;/code&gt; and &lt;code&gt;unstable_cacheTag&lt;/code&gt; can be simplified.&lt;/p&gt;

&lt;p&gt;When you review each &lt;code&gt;revalidateTag&lt;/code&gt; call during the upgrade, ask one question: would a user who just made this change be confused to see the old value? If yes, it should probably be &lt;code&gt;updateTag&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parallel route slots now need default.js
&lt;/h2&gt;

&lt;p&gt;Every parallel route slot now requires an explicit &lt;code&gt;default.js&lt;/code&gt; file, and builds fail without one. This one is loud, which makes it easy.&lt;/p&gt;

&lt;p&gt;To keep the previous behaviour, add a &lt;code&gt;default.js&lt;/code&gt; that either calls &lt;code&gt;notFound()&lt;/code&gt; or returns &lt;code&gt;null&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/@modal/default.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Default&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="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The decision between the two matters. Returning &lt;code&gt;null&lt;/code&gt; renders nothing in that slot on a hard navigation or reload. Calling &lt;code&gt;notFound()&lt;/code&gt; turns the whole route into a 404. Test both a direct visit and a browser refresh on any route that uses a modal or a slot, because that is where the difference shows. The &lt;a href="https://nextjs.org/docs/app/api-reference/file-conventions/default" rel="noopener noreferrer"&gt;default.js reference&lt;/a&gt; explains when each one fires.&lt;/p&gt;

&lt;h2&gt;
  
  
  next lint is gone, and next build no longer lints
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;next lint&lt;/code&gt; command has been removed, &lt;code&gt;next build&lt;/code&gt; no longer runs linting, and the &lt;code&gt;eslint&lt;/code&gt; option in &lt;code&gt;next.config&lt;/code&gt; is no longer supported. If your CI relied on the build step to catch lint errors, it has stopped doing that.&lt;/p&gt;

&lt;p&gt;The codemod migrates your scripts to the ESLint CLI:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Then add an explicit lint step to your pipeline before the build, so a lint failure blocks a merge the way it used to. Biome is an equally valid choice if you were already considering it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@next/eslint-plugin-next&lt;/code&gt; now defaults to the ESLint flat config format, in line with ESLint 10 dropping legacy configuration. If you still use &lt;code&gt;.eslintrc&lt;/code&gt;, the &lt;a href="https://nextjs.org/docs/app/api-reference/config/eslint" rel="noopener noreferrer"&gt;ESLint configuration reference&lt;/a&gt; covers the flat config setup. One practical note from the official troubleshooting section: migrating off &lt;code&gt;next lint&lt;/code&gt; does not require upgrading ESLint itself. &lt;code&gt;eslint-config-next@16&lt;/code&gt; needs ESLint 9, but if an ESLint upgrade causes peer dependency conflicts, you can restore your previous lint stack and run the upgrade with &lt;code&gt;--skip-eslint-upgrade&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Removed features to search your codebase for
&lt;/h2&gt;

&lt;p&gt;Several features deprecated in earlier versions are now removed outright. A quick search for each one tells you whether any of these sections apply to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AMP.&lt;/strong&gt; All AMP APIs are gone, including the &lt;code&gt;amp&lt;/code&gt; config, &lt;code&gt;useAmp&lt;/code&gt; from &lt;code&gt;next/amp&lt;/code&gt;, and &lt;code&gt;export const config = { amp: true }&lt;/code&gt;. If you still serve AMP pages, this is a product decision before it is a code change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Runtime configuration.&lt;/strong&gt; &lt;code&gt;serverRuntimeConfig&lt;/code&gt; and &lt;code&gt;publicRuntimeConfig&lt;/code&gt; have been removed. Server-only values come from &lt;code&gt;process.env&lt;/code&gt; directly in Server Components. Values the browser needs use the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix. Note that &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; values are inlined at build time, so if you relied on runtime config to change a value without rebuilding, call &lt;code&gt;await connection()&lt;/code&gt; from &lt;code&gt;next/server&lt;/code&gt; before reading &lt;code&gt;process.env&lt;/code&gt;, which forces the read to happen at request time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Experimental flags.&lt;/strong&gt; &lt;code&gt;experimental.dynamicIO&lt;/code&gt;, &lt;code&gt;experimental.useCache&lt;/code&gt; and the experimental Partial Prerendering flag are gone. If you were actively using them, the replacement is the top-level &lt;code&gt;cacheComponents&lt;/code&gt; option. If you were not, delete them rather than renaming them, because enabling &lt;code&gt;cacheComponents&lt;/code&gt; is a model change, not a rename.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smaller removals.&lt;/strong&gt; &lt;code&gt;unstable_rootParams&lt;/code&gt; is replaced by &lt;code&gt;next/root-params&lt;/code&gt;, and the &lt;code&gt;appIsrStatus&lt;/code&gt;, &lt;code&gt;buildActivity&lt;/code&gt; and &lt;code&gt;buildActivityPosition&lt;/code&gt; options under &lt;code&gt;devIndicators&lt;/code&gt; are removed, although the indicator itself remains.&lt;/p&gt;

&lt;p&gt;Search terms worth running: &lt;code&gt;next/amp&lt;/code&gt;, &lt;code&gt;RuntimeConfig&lt;/code&gt;, &lt;code&gt;getConfig&lt;/code&gt;, &lt;code&gt;dynamicIO&lt;/code&gt;, &lt;code&gt;useCache&lt;/code&gt;, &lt;code&gt;experimental_ppr&lt;/code&gt;, &lt;code&gt;rootParams&lt;/code&gt; and &lt;code&gt;devIndicators&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Smaller behaviour changes that surprise people
&lt;/h2&gt;

&lt;p&gt;A handful of changes do not break anything but will generate questions from your team or your clients. Knowing about them in advance saves time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Smooth scrolling between routes.&lt;/strong&gt; If your global CSS sets &lt;code&gt;scroll-behavior: smooth&lt;/code&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt;, earlier versions temporarily overrode it during navigation so route changes snapped to the top. Next.js 16 no longer does this, so navigations now animate. To restore the old behaviour, add &lt;code&gt;data-scroll-behavior="smooth"&lt;/code&gt; to the &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; element in your root layout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build output metrics.&lt;/strong&gt; The &lt;code&gt;size&lt;/code&gt; and &lt;code&gt;First Load JS&lt;/code&gt; columns are gone from &lt;code&gt;next build&lt;/code&gt; output because they were inaccurate with React Server Components. If someone on your team tracked bundle size from that output, switch to Lighthouse or real-user Core Web Vitals data. Our guide to &lt;a href="https://zignites.com/blog/mobile-first-design-core-web-vitals" rel="noopener noreferrer"&gt;passing Core Web Vitals on real phones&lt;/a&gt; covers how to measure what users actually experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate dev output.&lt;/strong&gt; &lt;code&gt;next dev&lt;/code&gt; now writes to &lt;code&gt;.next/dev&lt;/code&gt;, so dev and build can run at the same time, and a lockfile stops two instances of the same command from running on one project. Scripts that cleaned or inspected &lt;code&gt;.next&lt;/code&gt; directly may need a path update.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Config loaded once in development.&lt;/strong&gt; The config file is no longer loaded twice during &lt;code&gt;next dev&lt;/code&gt;, which means checking whether &lt;code&gt;process.argv&lt;/code&gt; includes &lt;code&gt;'dev'&lt;/code&gt; inside &lt;code&gt;next.config&lt;/code&gt; now returns &lt;code&gt;false&lt;/code&gt;. Plugins that used that check to start side processes should check &lt;code&gt;process.env.NODE_ENV === 'development'&lt;/code&gt; or use the config &lt;code&gt;phase&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefetching.&lt;/strong&gt; Navigation prefetching has been rebuilt with layout deduplication and incremental prefetching. You may see more individual prefetch requests in the network tab, each much smaller in total transfer size. This is expected and needs no code changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A testing plan that catches what the build misses
&lt;/h2&gt;

&lt;p&gt;The build tells you the code compiles. This testing plan tells you the app still behaves the same way. We run it on a preview deployment of the upgrade branch, side by side with production.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Images.&lt;/strong&gt; Open your heaviest image pages, typically the homepage, a product or portfolio page and a blog post, on both deployments. Compare visually at full size. Check the network tab for any &lt;code&gt;/_next/image&lt;/code&gt; request returning 400.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content freshness.&lt;/strong&gt; Replace an image and edit a piece of content in your CMS. Confirm both update within the time your editors expect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sitemaps and metadata.&lt;/strong&gt; Open every generated sitemap and check the first and last URLs. Share a page link in a social preview tool to confirm Open Graph images render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routes with slots or modals.&lt;/strong&gt; Visit each one directly, then refresh the browser on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxy or middleware.&lt;/strong&gt; Exercise every redirect, rewrite, auth gate and locale rule it handles. If you changed runtimes, measure latency from more than one region.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forms and Server Actions.&lt;/strong&gt; Submit every form that changes data and confirm the user sees their change immediately where they should.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Navigation feel.&lt;/strong&gt; Click through the main routes and confirm scroll behaviour matches what you intend.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI.&lt;/strong&gt; Push a commit with a deliberate lint error and confirm the pipeline fails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-user metrics.&lt;/strong&gt; After release, watch Core Web Vitals and server CPU for a few days. The image cache change usually lowers CPU, and a sudden rise is worth investigating.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you'd rather work from paper, the &lt;a href="https://zignites.com/contact?resource=nextjs16-upgrade&amp;amp;from=nextjs-16-upgrade-checklist" rel="noopener noreferrer"&gt;printable upgrade worksheet&lt;/a&gt; has this test plan as tick boxes with a pass condition for each one.&lt;/p&gt;

&lt;p&gt;The upgrade guide closes with its own migration checklist, and it is worth reading line by line before you merge. One line in particular is worth repeating to anyone in a hurry: do not enable React Compiler or Cache Components just to complete the upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you turn on React Compiler or Cache Components during the upgrade?
&lt;/h2&gt;

&lt;p&gt;No. Both are worth evaluating, and neither belongs in the same pull request as a major version upgrade.&lt;/p&gt;

&lt;p&gt;React Compiler support is stable in Next.js 16 through the top-level &lt;code&gt;reactCompiler&lt;/code&gt; option, following React Compiler 1.0. It is not enabled by default, it requires &lt;code&gt;babel-plugin-react-compiler&lt;/code&gt;, and the documentation notes that it increases compile times because it relies on Babel. It is a reasonable experiment once the upgrade has settled, measured against real render performance.&lt;/p&gt;

&lt;p&gt;Cache Components, enabled through &lt;code&gt;cacheComponents: true&lt;/code&gt;, is how Partial Prerendering works in Next.js 16. It can surface build errors for uncached data accessed outside a &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt; boundary and asks you to adopt a different caching model. The &lt;a href="https://nextjs.org/docs/app/guides/migrating-to-cache-components" rel="noopener noreferrer"&gt;Cache Components migration guide&lt;/a&gt; is the place to start when you are ready.&lt;/p&gt;

&lt;p&gt;Keeping these separate is basic change management. If something regresses after release, you want one change to suspect, not three. For teams building larger products, our post on &lt;a href="https://zignites.com/blog/multi-tenant-saas-architecture" rel="noopener noreferrer"&gt;multi-tenant SaaS architecture&lt;/a&gt; covers the same principle at the system level: isolate changes so failures stay small and traceable.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Next.js 16 Upgrade Worksheet&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;All 24 checks from this guide on two printable pages, each with a pass condition, plus a readiness score that tells you when it's safe to merge. No account, no sales sequence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://zignites.com/contact?resource=nextjs16-upgrade&amp;amp;from=nextjs-16-upgrade-checklist" rel="noopener noreferrer"&gt;Get the PDF&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Will my Next.js 15 site stop working after October 21, 2026?
&lt;/h3&gt;

&lt;p&gt;No. Your site keeps running exactly as it does today. What ends is support: Next.js 15 stops receiving the critical bug fixes and security updates it gets during Maintenance LTS. The risk is that the next security issue in the framework will be patched in Next.js 16, leaving you to upgrade under time pressure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I upgrade from Next.js 14 directly to Next.js 16?
&lt;/h3&gt;

&lt;p&gt;Yes, but you take on two sets of breaking changes at once. Read the Next.js 15 upgrade guide as well, because the async request API change started there, and run both the &lt;code&gt;upgrade&lt;/code&gt; codemod and the &lt;code&gt;next-async-request-api&lt;/code&gt; codemod. Next.js 14 has been unsupported since 26 October 2025, so this upgrade is overdue rather than optional.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I have to switch to Turbopack?
&lt;/h3&gt;

&lt;p&gt;No. Turbopack is the default, but you can keep webpack for builds with &lt;code&gt;next build --webpack&lt;/code&gt;. If your project has a custom webpack configuration, you must choose explicitly, because the build fails rather than ignoring it. Migrating to Turbopack is the recommended long-term path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does the upgrade codemod handle everything?
&lt;/h3&gt;

&lt;p&gt;No. It handles the Turbopack config move, the &lt;code&gt;next lint&lt;/code&gt; migration, the &lt;code&gt;middleware&lt;/code&gt; to &lt;code&gt;proxy&lt;/code&gt; rename, &lt;code&gt;unstable_&lt;/code&gt; prefixes and the &lt;code&gt;experimental_ppr&lt;/code&gt; segment config. It does not migrate synchronous request APIs, which need the separate &lt;code&gt;next-async-request-api&lt;/code&gt; codemod, and it cannot test the behaviour changes in images, caching, scrolling and CI linting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the minimum Node.js version for Next.js 16 really 20.9?
&lt;/h3&gt;

&lt;p&gt;Yes. Next.js 16 requires Node.js 20.9.0 or newer and no longer supports Node.js 18. Check the version in your hosting platform settings, CI configuration and Docker images, not just on your development machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Next.js 15 support ends on 21 October 2026. Run both codemods, fix what the build reports, then spend real time on the changes it cannot report: image quality and caching, sitemap IDs, CI linting, scroll behaviour and any edge middleware. Keep React Compiler and Cache Components for a later, separate change.&lt;/p&gt;

&lt;p&gt;If you are planning the rest of your stack for the coming year, our roundup of &lt;a href="https://zignites.com/blog/web-development-trends-2026" rel="noopener noreferrer"&gt;what actually changed in web development in 2026&lt;/a&gt; puts this release in context. The full detail for everything above lives in the official &lt;a href="https://nextjs.org/docs/app/guides/upgrading/version-16" rel="noopener noreferrer"&gt;Next.js 16 upgrade guide&lt;/a&gt;, which is worth keeping open in a tab while you work.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://zignites.com/blog/nextjs-16-upgrade-checklist" rel="noopener noreferrer"&gt;zignites.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>How to Tell If a WordPress Plugin Is Abandoned (One Field Won't Tell You)</title>
      <dc:creator>Muhammad Zeeshan Sardar</dc:creator>
      <pubDate>Fri, 02 Oct 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/zeeshansardar08/how-to-tell-if-a-wordpress-plugin-is-abandoned-one-field-wont-tell-you-186h</link>
      <guid>https://dev.to/zeeshansardar08/how-to-tell-if-a-wordpress-plugin-is-abandoned-one-field-wont-tell-you-186h</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The last release date is the strongest signal. "Tested up to" goes stale on its own. A closed listing means opposite things depending on the &lt;code&gt;reason&lt;/code&gt; field. Support-thread ratios are noisy. Premium plugins sit outside the directory entirely. There is a script at the end that checks a list of plugins in one run.&lt;/p&gt;

&lt;p&gt;Ask the WordPress.org plugin API about Display Widgets and you get 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;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"closed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This plugin has been closed as of January 30, 2021 and is not available for download. This closure is permanent. Reason: Security Issue."&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;Ask it about Paid Memberships Pro and you get 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;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"closed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This plugin has been closed as of October 17, 2024 and is not available for download. This closure is permanent. Reason: Author Request."&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;Same error, same wording, same word "permanent". But these are opposite situations. Display Widgets was pulled over a security issue. Paid Memberships Pro left the directory by its own authors' choice and kept shipping releases from its own site. It was still releasing updates in September 2026.&lt;/p&gt;

&lt;p&gt;Any check that stops at "is it listed?" treats those two plugins the same way. So does any check that stops at "when was it last updated?", for different reasons.&lt;/p&gt;

&lt;p&gt;There is no official definition of an abandoned plugin, and no single field that tells you. What you do have is a handful of facts the directory publishes, each of which means something narrower than it looks. Read them together and you get a reliable picture. This post goes through each one, then gives you a short script that checks a list of plugins in one run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get the facts yourself first
&lt;/h2&gt;

&lt;p&gt;Everything below comes from one public endpoint. No key, no signup:&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;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&amp;amp;request[slug]=akismet&amp;amp;request[fields][sections]=0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fields that matter for maintenance:&lt;/p&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;What it holds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;version&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The current release&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;last_updated&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When a release last shipped, e.g. &lt;code&gt;2026-08-18 11:42pm GMT&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tested&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The newest WordPress version the author says they tested against&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;requires_php&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The minimum PHP version the plugin declares&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;active_installs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A rounded floor, not an exact count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;support_threads&lt;/code&gt; / &lt;code&gt;support_threads_resolved&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Forum threads in the recent window, and how many are marked resolved&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a closed plugin you get &lt;code&gt;closed: true&lt;/code&gt;, a &lt;code&gt;closed_date&lt;/code&gt;, and a &lt;code&gt;reason&lt;/code&gt; such as &lt;code&gt;security-issue&lt;/code&gt; or &lt;code&gt;author-request&lt;/code&gt;, instead of the fields above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 1: the last release date (the strongest one)
&lt;/h2&gt;

&lt;p&gt;A release is the only thing in the list that proves someone recently did work on the plugin. A reasonable way to read it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Under 6 months:&lt;/strong&gt; actively maintained by any sensible standard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;6 to 11 months:&lt;/strong&gt; worth a look, especially for anything large.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;12 to 23 months:&lt;/strong&gt; ageing. Find out what you would replace it with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;24 months or more:&lt;/strong&gt; treat it as unmaintained until you learn otherwise.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The nuance is plugin size. A small plugin that does one narrow thing may genuinely need no changes for two years, and WordPress is careful about backwards compatibility. A page builder, a checkout extension or a membership system that has not shipped in two years is a different story, because those plugins touch everything that changes around them.&lt;/p&gt;

&lt;p&gt;The date is not the risk on its own. The risk is what happens when PHP or WordPress next ships a breaking change and nobody is there to fix the plugin. You want to know about that before your host upgrades PHP, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 2: "tested up to" (stale by design)
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;tested&lt;/code&gt; field is the author telling you the newest WordPress version they checked the plugin against. It goes stale on its own. WordPress ships two or three major releases a year, so a plugin that nobody touches falls behind whether or not anything in it is broken.&lt;/p&gt;

&lt;p&gt;WordPress.org shows a warning on a plugin's page once it has not been tested with the latest three major releases, which is a sensible line.&lt;/p&gt;

&lt;p&gt;Read it alongside the release date:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recent release, old "tested up to":&lt;/strong&gt; usually the author forgot to bump the header. Low concern, but test it on staging before a major WordPress upgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old release, old "tested up to":&lt;/strong&gt; both fields are telling you the same thing, and it is the release date that matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not add the two together as if they were separate problems. When the last release is over a year old, the "tested up to" gap is a symptom of the same fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 3: closed does not mean one thing
&lt;/h2&gt;

&lt;p&gt;This is the case from the top of the post. The &lt;code&gt;reason&lt;/code&gt; field is what separates them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;security-issue&lt;/code&gt;&lt;/strong&gt; (and the other reasons set by the plugin review team): the directory pulled the plugin. Take this seriously, find out why, and plan to replace it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;author-request&lt;/code&gt;:&lt;/strong&gt; the author asked for the listing to be removed. That can mean they gave up. It can also mean they moved distribution to their own site, which is exactly what happened with Paid Memberships Pro.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For author-request closures, the next step is to check the author's own site or repository for recent releases. If they are still shipping, the plugin is maintained. It simply is not in the directory any more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 4: support threads (noisy, use with care)
&lt;/h2&gt;

&lt;p&gt;It is tempting to read &lt;code&gt;support_threads_resolved / support_threads&lt;/code&gt; as a responsiveness score. Be careful.&lt;/p&gt;

&lt;p&gt;A thread only counts as resolved when someone marks it resolved. Plenty of active authors answer every thread and never tick the box, and plenty of users never come back to close their own threads. The ratio also needs a reasonable sample. Five threads with two resolved tells you almost nothing.&lt;/p&gt;

&lt;p&gt;Use it as a prompt to open the forum and read the last page of threads yourself. If recent questions have replies from the author, the plugin is supported, whatever the ratio says.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 5: premium plugins sit outside all of this
&lt;/h2&gt;

&lt;p&gt;Commercial plugins sold from the developer's own site are not in the directory, so none of the fields above exist for them. That is completely normal, and it carries its own risk.&lt;/p&gt;

&lt;p&gt;Premium plugins usually only get updates while the licence is active. When a renewal lapses, the plugin keeps running and quietly stops receiving fixes, and nothing on the site tells you. No outside check can see that.&lt;/p&gt;

&lt;p&gt;For premium plugins, the questions are not technical. Which licences are still active, and who is responsible for renewing them?&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;How to read it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Recent release, current "tested up to"&lt;/td&gt;
&lt;td&gt;Maintained&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recent release, old "tested up to"&lt;/td&gt;
&lt;td&gt;Maintained, test before major upgrades&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No release for 12+ months&lt;/td&gt;
&lt;td&gt;Ageing, know your replacement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No release for 24+ months, large plugin&lt;/td&gt;
&lt;td&gt;Treat as unmaintained&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Closed, &lt;code&gt;security-issue&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Replace it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Closed, &lt;code&gt;author-request&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Check the author's own site before deciding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not in the directory&lt;/td&gt;
&lt;td&gt;Premium or custom, check the licence&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A script to check a list of plugins
&lt;/h2&gt;

&lt;p&gt;Save this as &lt;code&gt;check-plugins.mjs&lt;/code&gt; and run it with Node 18 or later: &lt;code&gt;node check-plugins.mjs akismet contact-form-7 display-widgets&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.wordpress.org/plugins/info/1.2/&lt;/span&gt;&lt;span class="dl"&gt;'&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;monthsSince&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastUpdated&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;date&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lastUpdated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(\d)(&lt;/span&gt;&lt;span class="sr"&gt;am|pm&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$1 $2&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="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&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="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&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="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;30.44&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;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&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;url&lt;/span&gt; &lt;span class="o"&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;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?action=plugin_information&amp;amp;request[slug]=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;request[fields][sections]=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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closed&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: closed on &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closed_date&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: not in the directory (premium, custom, or a typo)`&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;months&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;monthsSince&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;last_updated&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;age&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;months&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&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;months&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; month&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;months&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="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;s&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; ago`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: v&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, last release &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;age&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, tested up to &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tested&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;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;slug&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&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 details in there are worth knowing. &lt;code&gt;last_updated&lt;/code&gt; comes back in a format like &lt;code&gt;2026-08-18 11:42pm GMT&lt;/code&gt;, which JavaScript's date parser does not reliably accept until the space before &lt;code&gt;pm&lt;/code&gt; is added. And a slug that has never existed also returns an &lt;code&gt;error&lt;/code&gt; without &lt;code&gt;closed&lt;/code&gt;, so "not in the directory" covers typos as well as premium plugins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing this for a whole site
&lt;/h2&gt;

&lt;p&gt;The script works when you already know the slugs. On a client's site you often do not, especially before you have a login.&lt;/p&gt;

&lt;p&gt;That is the gap I built &lt;a href="https://zignites.com/tools/wordpress-plugin-risk-report" rel="noopener noreferrer"&gt;a free WordPress plugin checker&lt;/a&gt; to fill. You paste a URL, and it detects the plugins the site loads publicly and reads the same directory fields covered above for each one. It separates plugin-team closures from author requests, marks which installed versions are inferred, and says plainly what it could not see. It does not make vulnerability claims, because that data sits behind commercial APIs and guessing at it does more harm than good.&lt;/p&gt;

&lt;p&gt;If you want the signals for one plugin at a time, there is also a &lt;a href="https://zignites.com/plugin-check" rel="noopener noreferrer"&gt;plugin maintenance check&lt;/a&gt; covering widely installed plugins, with the date each one was read from the directory.&lt;/p&gt;

&lt;p&gt;And if you are on the other side of this and maintain a plugin yourself, &lt;a href="https://zignites.com/blog/wordpress-plugin-development-guide" rel="noopener noreferrer"&gt;this guide to WordPress plugin development&lt;/a&gt; covers what keeping one healthy in the directory actually involves.&lt;/p&gt;

&lt;p&gt;The standard I would suggest for any site you are responsible for is simple. For every plugin that has gone a year without a release, know what you would replace it with before you need to. The day you need that answer is usually the day a host upgrades PHP.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The Obvious Way to Detect WordPress Plugins Counts WooCommerce Six Times</title>
      <dc:creator>Muhammad Zeeshan Sardar</dc:creator>
      <pubDate>Wed, 30 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/zeeshansardar08/the-obvious-way-to-detect-wordpress-plugins-counts-woocommerce-six-times-45eo</link>
      <guid>https://dev.to/zeeshansardar08/the-obvious-way-to-detect-wordpress-plugins-counts-woocommerce-six-times-45eo</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Asset paths, script handles and REST namespaces all reveal WordPress plugins from outside, and each one produces false positives if you trust it on its own. Deny core handles, map handle families to their parent plugin, require corroboration, and never trust a &lt;code&gt;?ver=&lt;/code&gt; that matches the core version.&lt;/p&gt;

&lt;p&gt;Point a naive plugin detector at a WooCommerce store and it will tell you the site runs &lt;code&gt;wc&lt;/code&gt;, &lt;code&gt;wc-admin&lt;/code&gt;, &lt;code&gt;wc-analytics&lt;/code&gt;, &lt;code&gt;wc-telemetry&lt;/code&gt;, &lt;code&gt;wccom-site&lt;/code&gt; and &lt;code&gt;wc-admin-email&lt;/code&gt;. Six plugins. It is one plugin, WooCommerce, and the real &lt;code&gt;woocommerce&lt;/code&gt; slug may not appear in the list at all.&lt;/p&gt;

&lt;p&gt;The same detector will report &lt;code&gt;wp-block-editor&lt;/code&gt; and &lt;code&gt;wp-site-health&lt;/code&gt; as plugins. Both are WordPress core.&lt;/p&gt;

&lt;p&gt;I build WordPress plugins, and I recently built a free scanner that reads a public site and lists the plugins it runs, with the maintenance facts WordPress.org publishes about each one. The use case is quoting: before a client hands over a login, you want to know whether you are walking into twelve current plugins or four that have not shipped a release since 2022.&lt;/p&gt;

&lt;p&gt;Getting that list right turned out to be the whole job. Detection from outside is mostly an exercise in not believing your own evidence too quickly. Here are the three signals that work, the trap in each one, and the rules that keep the list honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 1: asset paths (the one to trust most)
&lt;/h2&gt;

&lt;p&gt;Plugins live in &lt;code&gt;/wp-content/plugins/&amp;lt;slug&amp;gt;/&lt;/code&gt;, and when a plugin loads a stylesheet, script or image on a page, that path shows up in the HTML.&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;PLUGIN_PATH&lt;/span&gt; &lt;span class="o"&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;wp-content&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;plugins&lt;/span&gt;&lt;span class="se"&gt;\/([&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9-&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;/gi&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;slugsFromPaths&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Set&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="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;slugs&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;Set&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="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;match&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PLUGIN_PATH&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;slugs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&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="nf"&gt;toLowerCase&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;slugs&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;When a slug appears in an asset path, the plugin is installed and active on that page. That is about as strong as outside evidence gets, and the directory name matches the WordPress.org slug for anything installed from the directory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trap:&lt;/strong&gt; absence proves nothing. Three things hide plugins from this signal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Optimisation and caching plugins&lt;/strong&gt; that combine every stylesheet and script into one bundle served from their own cache folder. The individual plugin paths disappear from the HTML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plugins with no front-end assets.&lt;/strong&gt; Anything that works entirely in wp-admin or on the server leaves no trace in the page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security plugins&lt;/strong&gt; that rewrite or hide plugin paths on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a short list from this signal is evidence of what is visible, not evidence of a small install. That distinction has to reach the person reading the report, or they will draw the wrong conclusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 2: script and style handles (useful, and full of traps)
&lt;/h2&gt;

&lt;p&gt;When WordPress prints an enqueued script or stylesheet, it adds an ID built from the handle the code registered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"contact-form-7-css"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"wc-add-to-cart-js"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strip the &lt;code&gt;-js&lt;/code&gt; or &lt;code&gt;-css&lt;/code&gt; suffix and you have the handle. This signal matters because it survives some of the situations that hide asset paths, including assets served from a CDN under a different URL structure.&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;HANDLE_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;script|link&lt;/span&gt;&lt;span class="se"&gt;)\b[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;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;\b&lt;/span&gt;&lt;span class="sr"&gt;id=&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;a-z0-9_-&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="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;js|css&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;/gi&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;handlesFromIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Set&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="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;handles&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;Set&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="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;match&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HANDLE_ID&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;handles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&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="nf"&gt;toLowerCase&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;handles&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 regex requires the closing quote straight after &lt;code&gt;-js&lt;/code&gt;, which quietly skips inline companions such as &lt;code&gt;wc-add-to-cart-js-extra&lt;/code&gt; and &lt;code&gt;-js-after&lt;/code&gt;. Those belong to a handle you have already counted.&lt;/p&gt;

&lt;p&gt;This is where the six WooCommerce rows come from. A handle is not a slug. It is whatever string the developer chose, and three kinds of handle break the naive approach. (Core packages also start with &lt;code&gt;wp-&lt;/code&gt;, so a blanket "anything beginning with wp-" rule would throw away real plugins such as &lt;code&gt;wp-mail-smtp&lt;/code&gt;. The deny list has to be explicit.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Core handles.&lt;/strong&gt; WordPress registers dozens of its own scripts: &lt;code&gt;wp-block-editor&lt;/code&gt;, &lt;code&gt;wp-polyfill&lt;/code&gt;, &lt;code&gt;jquery-core&lt;/code&gt;, &lt;code&gt;wp-emoji-release&lt;/code&gt;, &lt;code&gt;wp-block-library&lt;/code&gt;, &lt;code&gt;global-styles&lt;/code&gt;, &lt;code&gt;classic-theme-styles&lt;/code&gt; and more. None of them is a plugin. You need an explicit deny list, and it needs a comment saying where it came from, because it will need extending every few releases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Handle families.&lt;/strong&gt; One plugin often registers many handles under a shared prefix. WooCommerce uses &lt;code&gt;wc-*&lt;/code&gt; and &lt;code&gt;wccom-*&lt;/code&gt;. Yoast uses &lt;code&gt;yoast-*&lt;/code&gt; and &lt;code&gt;wpseo-*&lt;/code&gt;, and its directory slug is &lt;code&gt;wordpress-seo&lt;/code&gt;, which no handle contains. You need a maintained map from prefix to parent slug, and you emit one row for the parent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Theme handles.&lt;/strong&gt; Themes enqueue assets too. A theme stylesheet with the ID &lt;code&gt;acme-siteheader-css&lt;/code&gt; produces the handle &lt;code&gt;acme-siteheader&lt;/code&gt;, which looks exactly like a plugin that does not exist.&lt;/p&gt;

&lt;p&gt;The rule that fixes all three is corroboration. A slug derived only from a handle is accepted when it resolves in the WordPress.org directory, or when the same slug also appears as a real asset path on the site. Anything else is dropped silently.&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;// Core script and style handles. Extend this as WordPress adds packages.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CORE_HANDLES&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;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jquery&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;jquery-core&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;jquery-migrate&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;wp-polyfill&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;wp-emoji-release&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;wp-block-library&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;wp-block-editor&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;wp-site-health&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;global-styles&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;classic-theme-styles&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;admin-bar&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;regenerator-runtime&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;lodash&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;moment&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;// Prefix to parent slug. Order matters: more specific prefixes first.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HANDLE_FAMILIES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="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="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wccom-&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;woocommerce&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wc-&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;woocommerce&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wpseo-&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;wordpress-seo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yoast-&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;wordpress-seo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;elementor-&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;elementor&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;function&lt;/span&gt; &lt;span class="nf"&gt;familyParent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wc&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;woocommerce&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;HANDLE_FAMILIES&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;prefix&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;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prefix&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;match&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&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;acceptHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;handle&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;pathSlugs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Set&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="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;directorySlugs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Set&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="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CORE_HANDLES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handle&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;parent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;familyParent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;handle&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;pathSlugs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;directorySlugs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parent&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;parent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dropping silently is the important part. The tempting alternative is to show unmatched handles as "not in directory". That turns a theme stylesheet into a finding, and a WordPress developer will spot it in the first ten seconds and stop trusting everything else on the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signal 3: REST API namespaces
&lt;/h2&gt;

&lt;p&gt;Most WordPress sites expose an index at &lt;code&gt;/wp-json/&lt;/code&gt;, and its &lt;code&gt;namespaces&lt;/code&gt; array lists the route prefixes every active plugin has registered:&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;"namespaces"&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;"oembed/1.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wc/v3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"yoast/v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"contact-form-7/v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wp/v2"&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;This catches plugins that load nothing on the front end but still register routes, which fills part of the gap Signal 1 leaves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trap:&lt;/strong&gt; namespaces are not slugs either. &lt;code&gt;wp/v2&lt;/code&gt; and &lt;code&gt;oembed/1.0&lt;/code&gt; are core. &lt;code&gt;yoast/v1&lt;/code&gt; belongs to &lt;code&gt;wordpress-seo&lt;/code&gt;. &lt;code&gt;wc/v3&lt;/code&gt; belongs to &lt;code&gt;woocommerce&lt;/code&gt;. The same family map from Signal 2 does most of the work here, and the same corroboration rule applies.&lt;/p&gt;

&lt;p&gt;It is also the weakest of the three signals. A namespace proves the plugin registered routes. It does not prove the plugin does anything visible, and some sites disable or restrict the REST index. When you report a plugin, record which signal found it (path, handle, namespace, or several), so you can tell later how much weight each row deserves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the installed version
&lt;/h2&gt;

&lt;p&gt;Knowing a plugin is installed is half of it. Knowing the site runs 4.2 while the current release is 6.1 is the part that matters for a quote.&lt;/p&gt;

&lt;p&gt;The best source is the plugin's own &lt;code&gt;readme.txt&lt;/code&gt;, fetched from &lt;code&gt;/wp-content/plugins/&amp;lt;slug&amp;gt;/readme.txt&lt;/code&gt;, which carries a &lt;code&gt;Stable tag&lt;/code&gt; line. Many sites block direct requests to plugin folders, and larger ones block them almost always.&lt;/p&gt;

&lt;p&gt;The fallback is the &lt;code&gt;ver&lt;/code&gt; query string on asset URLs:&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;function&lt;/span&gt; &lt;span class="nf"&gt;versionFromAsset&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;pageUrl&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;coreVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="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;ver&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageUrl&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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;ver&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;ver&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coreVersion&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ver&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;coreVersion&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ver&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 core version check exists because of how &lt;code&gt;wp_register_script&lt;/code&gt; behaves. When a plugin registers an asset without passing its own version, WordPress appends the core version instead. Read that at face value and you will report that a plugin is on version 6.8, when 6.8 is simply the WordPress version.&lt;/p&gt;

&lt;p&gt;Two more rules make this usable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Label it.&lt;/strong&gt; A version from a readme is confirmed. A version from &lt;code&gt;?ver=&lt;/code&gt; is inferred, because it is a cache-busting parameter and a site can put anything in it. The report should say which is which.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find the core version from core assets&lt;/strong&gt;, such as the &lt;code&gt;ver&lt;/code&gt; on files under &lt;code&gt;/wp-includes/&lt;/code&gt;. The generator meta tag is often removed, so it cannot be the only source.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the directory can tell you, and what it cannot
&lt;/h2&gt;

&lt;p&gt;Once you have a clean list of slugs, the WordPress.org plugin API gives you the maintenance facts for each one: when it last shipped a release, which WordPress version its author last tested against, and whether the directory still lists it.&lt;/p&gt;

&lt;p&gt;What it does not give you is vulnerability data. I made a deliberate decision not to guess at it. Good vulnerability data for WordPress plugins sits behind commercial APIs, and a free tool that fakes it has two options. It can guess from version numbers, which flags plugins that are fine. Or it can flag a few famous cases and stay quiet on the rest, which implies everything unflagged has been checked. Both are worse than saying clearly that no security check was run.&lt;/p&gt;

&lt;p&gt;The same principle applies to the score. If a site loads 11 plugins and 8 of them are premium plugins sold outside the directory, there is no public release history to judge those 8 on. A score built on the remaining 3 should say so, rather than printing "Healthy" next to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The standard I hold a scanner to
&lt;/h2&gt;

&lt;p&gt;Fewer, truer findings beat a longer list. Every rule above removes rows rather than adding them: the deny list, the family map, the corroboration check and the core version check. Each one exists because a plausible-looking false positive costs more trust than a missing row.&lt;/p&gt;

&lt;p&gt;If you want to see the result on a real site, the scanner is free and needs no signup: &lt;a href="https://zignites.com/tools/wordpress-plugin-risk-report" rel="noopener noreferrer"&gt;WordPress Plugin Checker&lt;/a&gt;. It marks which installed versions are inferred rather than confirmed, and states plainly what it could not see. If you only care about one plugin, the &lt;a href="https://zignites.com/plugin-check" rel="noopener noreferrer"&gt;plugin maintenance check&lt;/a&gt; covers widely installed plugins one at a time.&lt;/p&gt;

&lt;p&gt;If you build plugins yourself, I wrote about &lt;a href="https://zignites.com/blog/wordpress-plugin-development-guide" rel="noopener noreferrer"&gt;what maintaining one actually involves&lt;/a&gt;, including why "tested up to" goes stale on its own.&lt;/p&gt;

&lt;p&gt;I would like to hear from anyone who has built detection like this. Which handle families or namespaces have caught you out? The family map only gets better with more eyes on it.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Export Installed WordPress Plugins &amp; Themes to CSV (Developer-Friendly Method)</title>
      <dc:creator>Muhammad Zeeshan Sardar</dc:creator>
      <pubDate>Mon, 09 Mar 2026 23:08:21 +0000</pubDate>
      <link>https://dev.to/zeeshansardar08/export-installed-wordpress-plugins-themes-to-csv-developer-friendly-method-2cpa</link>
      <guid>https://dev.to/zeeshansardar08/export-installed-wordpress-plugins-themes-to-csv-developer-friendly-method-2cpa</guid>
      <description>&lt;p&gt;If you manage WordPress websites, you've probably encountered this problem.&lt;/p&gt;

&lt;p&gt;You open a project and need to quickly answer questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What plugins are installed on this site?&lt;/li&gt;
&lt;li&gt;Which theme is active?&lt;/li&gt;
&lt;li&gt;How can I export this list for documentation or auditing?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Surprisingly, WordPress does not provide a built-in way to export installed plugins and themes.&lt;/p&gt;

&lt;p&gt;For developers and agencies managing multiple projects, this can become frustrating.&lt;/p&gt;

&lt;p&gt;So I built a small tool to solve this problem.&lt;/p&gt;

&lt;p&gt;In this article, I’ll show you how to export installed WordPress plugins and themes in seconds.&lt;/p&gt;

&lt;p&gt;If you just want the quick solution:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install Site Extensions Snapshot&lt;/li&gt;
&lt;li&gt;Go to Tools → Site Extensions Snapshot&lt;/li&gt;
&lt;li&gt;Click Export to CSV
You will instantly download a list of all installed plugins and themes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Plugin link:&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://wordpress.org/plugins/site-extensions-snapshot/" rel="noopener noreferrer"&gt;https://wordpress.org/plugins/site-extensions-snapshot/&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Developers Need a Plugin Snapshot
&lt;/h2&gt;

&lt;p&gt;In real-world development, having a snapshot of installed extensions is extremely useful.&lt;/p&gt;

&lt;p&gt;Here are a few common scenarios.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client Documentation&lt;/strong&gt;&lt;br&gt;
When handing over a project, you may need to document all plugins and themes used in the website.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Website Audits&lt;/strong&gt;&lt;br&gt;
Security or performance audits often require reviewing installed plugins.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Troubleshooting&lt;/strong&gt;&lt;br&gt;
Plugin conflicts are common in WordPress. Having a full list helps identify potential issues faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Migration Preparation&lt;/strong&gt;&lt;br&gt;
Before migrating a website, it's helpful to know exactly which extensions are installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with WordPress
&lt;/h2&gt;

&lt;p&gt;WordPress lets you view plugins inside the dashboard, but it doesn't allow you to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Export the plugin list&lt;/li&gt;
&lt;li&gt;Download the plugin inventory&lt;/li&gt;
&lt;li&gt;Share it with a team&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most developers end up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Manually copying plugin names&lt;/li&gt;
&lt;li&gt;Taking screenshots&lt;/li&gt;
&lt;li&gt;Writing custom scripts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This wastes time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: Site Extensions Snapshot
&lt;/h2&gt;

&lt;p&gt;To make this easier, I built a small WordPress plugin called:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Site Extensions Snapshot&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It allows developers to export installed plugins and themes into a CSV file with one click.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plugin page:&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://wordpress.org/plugins/site-extensions-snapshot/" rel="noopener noreferrer"&gt;https://wordpress.org/plugins/site-extensions-snapshot/&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Plugin Does
&lt;/h2&gt;

&lt;p&gt;Once installed, the plugin allows you to:&lt;/p&gt;

&lt;p&gt;✔ Export installed plugins to CSV&lt;br&gt;
✔ Export installed themes to CSV&lt;br&gt;
✔ Create a quick snapshot of the site environment&lt;br&gt;
✔ Use the exported list for documentation or audits&lt;/p&gt;

&lt;p&gt;It's a lightweight utility tool designed to save developers time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Use the Plugin
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — Install the Plugin&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Go to your WordPress dashboard:&lt;br&gt;
Plugins → Add New&lt;br&gt;
Search for:&lt;br&gt;
Site Extensions Snapshot&lt;br&gt;
Install and activate it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — Export Installed Plugins &amp;amp; Themes&lt;/strong&gt;&lt;br&gt;
After activating the plugin:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to Tools&lt;/li&gt;
&lt;li&gt;Click Site Extensions Snapshot&lt;/li&gt;
&lt;li&gt;Click Export to CSV
That's it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The plugin will download a CSV file containing all installed plugins and themes.&lt;br&gt;
You can now use this file for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Documentation&lt;/li&gt;
&lt;li&gt;Website audits&lt;/li&gt;
&lt;li&gt;Migration planning&lt;/li&gt;
&lt;li&gt;Team collaboration&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Who This Plugin Is For
&lt;/h2&gt;

&lt;p&gt;This tool is useful for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WordPress developers&lt;/li&gt;
&lt;li&gt;Freelancers&lt;/li&gt;
&lt;li&gt;Agencies&lt;/li&gt;
&lt;li&gt;Website auditors&lt;/li&gt;
&lt;li&gt;DevOps teams managing WordPress environments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you work with multiple WordPress projects, this tool can simplify documentation and save time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I Built This Plugin&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;While working on WordPress projects, I often needed a quick list of installed plugins and themes for documentation or troubleshooting.&lt;/p&gt;

&lt;p&gt;Since there wasn't an easy way to export that information, I decided to build a small tool that solves this problem.&lt;/p&gt;

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

&lt;p&gt;If you'd like to try the plugin, you can install it from the WordPress plugin directory.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://wordpress.org/plugins/site-extensions-snapshot/" rel="noopener noreferrer"&gt;https://wordpress.org/plugins/site-extensions-snapshot/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you try it, I’d love to hear feedback or suggestions from the developer community.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8g2m1wjx75bf055lilg3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8g2m1wjx75bf055lilg3.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
Sometimes the simplest tools solve real developer problems.&lt;/p&gt;

&lt;p&gt;If you regularly manage WordPress websites, having a quick way to export installed plugins and themes can make documentation and audits much easier.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>wordpress</category>
      <category>opensource</category>
      <category>devtools</category>
    </item>
  </channel>
</rss>
