<?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: uninterrupted</title>
    <description>The latest articles on DEV Community by uninterrupted (@engineering).</description>
    <link>https://dev.to/engineering</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%2F3290827%2F5ccc7612-9770-41ed-a766-77b0557e8c3a.jpg</url>
      <title>DEV Community: uninterrupted</title>
      <link>https://dev.to/engineering</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/engineering"/>
    <language>en</language>
    <item>
      <title>Achieving Instant Navigations in Next.js 16.3</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Wed, 07 Oct 2026 07:09:06 +0000</pubDate>
      <link>https://dev.to/u11d/achieving-instant-navigations-in-nextjs-163-47ok</link>
      <guid>https://dev.to/u11d/achieving-instant-navigations-in-nextjs-163-47ok</guid>
      <description>&lt;p&gt;Server Components can reduce client-side JavaScript, keep sensitive logic on the server, and prevent data-fetching waterfalls. But they also introduced a frustrating user experience in many dynamic applications: after clicking a link, the user sometimes had to wait for the server before seeing the next screen.&lt;/p&gt;

&lt;p&gt;Next.js 16.3 addresses this problem with Instant Navigations, an opt-in set of rendering, prefetching, development, and testing tools. The goal is not to make every data request finish immediately. It is to make sure the application always has meaningful UI ready when navigation begins.&lt;/p&gt;

&lt;p&gt;This is particularly useful for e-commerce platforms, dashboards, and account areas. These applications cannot prerender every personalized state at build time, but they should still respond to clicks without a visible pause.&lt;/p&gt;

&lt;p&gt;In this guide, I will explain how Instant Navigations work, how to choose between streaming and caching, and how to protect navigation performance with Playwright tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Dynamic Next.js Navigations Can Feel Slow
&lt;/h2&gt;

&lt;p&gt;In a traditional server-driven navigation, the sequence often looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The user clicks a link.&lt;/li&gt;
&lt;li&gt;The browser requests the target route from the server.&lt;/li&gt;
&lt;li&gt;The server reads data and renders the route.&lt;/li&gt;
&lt;li&gt;The browser finally displays the next UI.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The architecture can be efficient, but the delay between the first and fourth steps is visible. A client-driven SPA usually feels more responsive because it already has enough code in the browser to display the next screen or its loading state immediately.&lt;/p&gt;

&lt;p&gt;Before Next.js 16.3, teams had two common ways to reduce this delay:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;prerender the full page at build time, which does not work for every dynamic or personalized route&lt;/li&gt;
&lt;li&gt;fully prefetch each destination with &lt;code&gt;&amp;lt;Link prefetch={true}&amp;gt;&lt;/code&gt;, which can generate unnecessary requests and increase server load&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A &lt;code&gt;loading.tsx&lt;/code&gt; file could provide an immediate loading state, but it was easy to forget and applied at the route-segment level rather than around the exact data dependency.&lt;/p&gt;

&lt;p&gt;Instant Navigations provide a more precise model. Instead of asking whether the whole route is static or dynamic, you decide which parts can be ready before the click and which parts should arrive later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enabling Instant Navigations in Next.js 16.3
&lt;/h2&gt;

&lt;p&gt;Instant Navigations are opt-in in Next.js 16.3. Enable Cache Components and Partial Prefetching in &lt;code&gt;next.config.ts&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="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;cacheComponents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;partialPrefetching&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cacheComponents&lt;/code&gt; enables the explicit caching model built around &lt;code&gt;'use cache'&lt;/code&gt;, &lt;code&gt;cacheLife&lt;/code&gt;, and &lt;code&gt;cacheTag&lt;/code&gt;. It also allows routes to combine prerendered UI with dynamic content behind Suspense boundaries.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;partialPrefetching&lt;/code&gt; changes how Next.js prepares routes on the client. Instead of treating prefetching as an all-or-nothing decision, Next.js can prefetch only the reusable UI needed to start a navigation.&lt;/p&gt;

&lt;p&gt;Enabling&amp;nbsp;&lt;code&gt;cacheComponents&lt;/code&gt;&amp;nbsp;is a migration step, not just a performance flag. During production builds, Next.js attempts to prerender as much of a route as possible and can report an error when runtime or uncached data is accessed outside a Suspense boundary. Each case must be streamed behind Suspense, cached with&amp;nbsp;&lt;code&gt;'use cache'&lt;/code&gt;&amp;nbsp;when appropriate, or explicitly allowed to block with&amp;nbsp;&lt;code&gt;export const instant = false&lt;/code&gt;. Existing applications may also need to remove incompatible route segment configuration such as&amp;nbsp;&lt;code&gt;dynamic&lt;/code&gt;,&amp;nbsp;&lt;code&gt;revalidate&lt;/code&gt;, or&amp;nbsp;&lt;code&gt;fetchCache&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;These behaviors are planned to become defaults in a future major version. In 16.3, enabling them explicitly gives teams time to review their asynchronous boundaries and migration requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Model: Stream, Cache, or Block
&lt;/h2&gt;

&lt;p&gt;When a route depends on asynchronous data, Next.js 16.3 asks you to make the intended behavior explicit. There are three useful choices.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Stream dynamic content with Suspense
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt; when the data must stay fresh or depends on the active request. The fallback becomes part of the route shell, so it can appear immediately while the server renders the final content.&lt;/p&gt;

&lt;p&gt;This is a good fit for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;inventory and delivery estimates&lt;/li&gt;
&lt;li&gt;cart state&lt;/li&gt;
&lt;li&gt;account-specific data&lt;/li&gt;
&lt;li&gt;live analytics&lt;/li&gt;
&lt;li&gt;personalized recommendations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The navigation is instant because the user sees the fallback without waiting for the dynamic operation. The final data still arrives from the server later.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Cache reusable content with &lt;code&gt;'use cache'&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;'use cache'&lt;/code&gt; when the result can be safely reused. Cached UI can be included in the prefetched response and displayed immediately during navigation.&lt;/p&gt;

&lt;p&gt;This works well for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;product descriptions&lt;/li&gt;
&lt;li&gt;category content&lt;/li&gt;
&lt;li&gt;CMS blocks&lt;/li&gt;
&lt;li&gt;public pricing shared by a region&lt;/li&gt;
&lt;li&gt;configuration that changes infrequently&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cache boundary should follow the business meaning of the data. Content that is safe to share globally should not be mixed with customer-specific prices or session state.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Allow the route to block
&lt;/h3&gt;

&lt;p&gt;Not every route needs to navigate instantly. If displaying a loading state would be misleading and the route should wait for complete server output, mark that decision explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;instant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This can be appropriate for a simple content page where the team prefers to show the complete article at once, or for a flow where a partial state would create confusion.&lt;/p&gt;

&lt;p&gt;The important change is that blocking becomes an intentional route decision rather than an accidental result of one unresolved data dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Partial Prefetching Changes the Network Cost
&lt;/h2&gt;

&lt;p&gt;The previous prefetching model could issue a request for every visible link. Consider an account sidebar with twenty links to &lt;code&gt;/orders/[id]&lt;/code&gt;. Even though all links share the same route structure, the browser could prefetch each destination separately.&lt;/p&gt;

&lt;p&gt;With Partial Prefetching, Next.js can generate one reusable shell for &lt;code&gt;/orders/[id]&lt;/code&gt;, store it in the browser, and use it for multiple links during the session. This is closer to route-level code splitting in a SPA: the browser receives enough UI to react immediately, but it does not need the complete data for every possible destination.&lt;/p&gt;

&lt;p&gt;If a particular link deserves a deeper prefetch, you can still opt in:&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="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;product&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;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;prefetch&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="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;Next.js then prerenders as much of that destination as it safely can. Synchronous values from the URL and content marked with &lt;code&gt;'use cache'&lt;/code&gt; can be included, while request-bound work remains behind its Suspense boundary.&lt;/p&gt;

&lt;p&gt;This avoids a common tradeoff in dynamic applications: either prefetch nothing and make the user wait, or prefetch complete pages and make the server do work that may never be used.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical E-Commerce Example
&lt;/h2&gt;

&lt;p&gt;A product page usually combines data with very different freshness requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;product copy and media are broadly reusable&lt;/li&gt;
&lt;li&gt;inventory changes often&lt;/li&gt;
&lt;li&gt;customer pricing may depend on identity, region, or sales channel&lt;/li&gt;
&lt;li&gt;the cart belongs to the current session&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Making the whole page dynamic means one slow operation can block navigation. Caching the whole page is more dangerous because it can expose stale or private data.&lt;/p&gt;

&lt;p&gt;A better boundary looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Suspense&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cacheTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductSummary&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="nl"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

  &lt;span class="nf"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`product:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hours&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getProduct&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Inventory&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="nl"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inventory&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;getLiveInventory&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;available&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; items available&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductPage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="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;/products/[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;params&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProductSummary&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;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Checking availability...&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Inventory&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;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;main&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The product summary can be cached and reused. Live inventory remains dynamic, but it no longer prevents the route from showing useful content. During navigation, the browser can display the product UI or its prepared shell first and fill in availability when the server responds.&lt;/p&gt;

&lt;p&gt;For a real storefront, customer-specific pricing should follow the same principle as inventory: isolate it in a private or request-time boundary instead of adding cookies or session access to the entire page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding Slow Routes with Instant Insights
&lt;/h2&gt;

&lt;p&gt;Performance problems are difficult to fix when they only appear as a vague feeling that the application is slow. Next.js 16.3 adds Instant Insights to the development tools to make the problem visible.&lt;/p&gt;

&lt;p&gt;When you encounter a non-instant navigation during development, Instant Insights identifies the route and points to the asynchronous work that blocks its shell. In most cases, the fix is one of the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;move request-specific work below a Suspense boundary&lt;/li&gt;
&lt;li&gt;mark reusable work with &lt;code&gt;'use cache'&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;move a blocking dependency out of a shared layout&lt;/li&gt;
&lt;li&gt;explicitly accept blocking with &lt;code&gt;export const instant = false&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Navigation Inspector complements this by pausing navigation at the prefetched shell. This lets you inspect exactly what the user can see before dynamic content arrives. It is useful because production prefetching is normally disabled in development, where loading behavior can otherwise be difficult to reproduce accurately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Instant Navigations with Playwright
&lt;/h2&gt;

&lt;p&gt;A route that feels instant today can become blocking after a small refactor. A new &lt;code&gt;cookies()&lt;/code&gt; call in a shared component or a moved Suspense boundary may be enough to change the result.&lt;/p&gt;

&lt;p&gt;Next.js 16.3 includes the &lt;code&gt;instant()&lt;/code&gt; helper in &lt;code&gt;@next/playwright&lt;/code&gt;. It temporarily holds back dynamic content, allowing the test to assert which UI is available without waiting for the network:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;test&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;@playwright/test&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;instant&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/playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product navigation shows useful UI instantly&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;page&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/products/shoes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;instant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a[href="/products/hats"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForURL&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/products/hats&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;h1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toContainText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Baseball Cap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Checking availability...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/items available/&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&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 test describes a user-facing contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the product title must be available immediately&lt;/li&gt;
&lt;li&gt;the inventory fallback must be visible while live data is pending&lt;/li&gt;
&lt;li&gt;the final inventory must appear after the dynamic work completes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is more useful than testing an arbitrary duration such as 100 milliseconds. Network and CI timing can vary, but the boundary between prefetched UI and network-dependent UI is deterministic.&lt;/p&gt;

&lt;p&gt;The v0 team used this property to improve real routes with a coding agent. The agent wrote a failing &lt;code&gt;instant()&lt;/code&gt; test, moved or cached the blocking work, rebuilt the application, and repeated the process until the test passed. The resulting 16 tests were kept in CI to prevent future regressions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Teams Usually Get It Wrong
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Mistake 1: Treating “instant” as “fully loaded”
&lt;/h3&gt;

&lt;p&gt;Instant Navigations do not make databases or APIs respond with zero latency. They guarantee that useful UI can respond to the click without waiting for those systems.&lt;/p&gt;

&lt;p&gt;The distinction matters. A complete page may still stream in over time, but the application should never look unresponsive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 2: Placing Suspense too high in the tree
&lt;/h3&gt;

&lt;p&gt;Wrapping an entire page in one Suspense boundary may technically make navigation instant, but the user will see a generic full-page skeleton. Place boundaries close to the data that actually needs to wait so stable navigation, headings, and controls remain visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 3: Reading dynamic data in a shared layout
&lt;/h3&gt;

&lt;p&gt;A session or request-dependent operation near the root can block many routes at once. Keep shared layouts focused on reusable UI and move dynamic reads into the components that need them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 4: Enabling full prefetching on every link
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;prefetch={true}&lt;/code&gt; is still useful, but it should represent a deliberate choice to prepare more than the route shell. Applying it everywhere can recreate the server and network cost that Partial Prefetching is designed to avoid.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 5: Using meaningless loading states
&lt;/h3&gt;

&lt;p&gt;An instant blank rectangle is not a good user experience. The shell should preserve the layout and provide enough context for the user to understand where the navigation is going.&lt;/p&gt;

&lt;p&gt;For a product page, that may include the title, gallery frame, price area, and an inventory placeholder. For an account page, it may include the navigation, page heading, and form structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical Adoption Strategy
&lt;/h2&gt;

&lt;p&gt;For an existing Next.js application, avoid changing every route at once. Start with the journeys where a delay has the largest user or business impact.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enable &lt;code&gt;cacheComponents&lt;/code&gt; and &lt;code&gt;partialPrefetching&lt;/code&gt; in a branch.&lt;/li&gt;
&lt;li&gt;Use Instant Insights to identify blocking navigations.&lt;/li&gt;
&lt;li&gt;Choose one important journey, such as category to product or cart to checkout.&lt;/li&gt;
&lt;li&gt;Write a failing test with &lt;code&gt;instant()&lt;/code&gt; that describes the minimum UI required after the click.&lt;/li&gt;
&lt;li&gt;Stream request-bound data and cache only content that is safe to reuse.&lt;/li&gt;
&lt;li&gt;Inspect the shell with Navigation Inspector.&lt;/li&gt;
&lt;li&gt;Keep the test in CI after the route passes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This process turns perceived performance into an architectural requirement that can be reviewed and tested. It also creates a clear stopping point: the work is complete when the intended shell is visible without network-dependent content.&lt;/p&gt;

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

&lt;p&gt;The most important part of Instant Navigations is not another prefetch option. It is the clearer boundary between UI that must be ready for a click and data that is allowed to arrive later.&lt;/p&gt;

&lt;p&gt;For dynamic applications, this removes the need to choose between a server-driven architecture and SPA-like responsiveness. Server Components can continue to handle rendering and sensitive data, while Suspense, Cache Components, and reusable route shells keep navigation responsive.&lt;/p&gt;

&lt;p&gt;The best result comes from combining three decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stream data that must remain dynamic&lt;/li&gt;
&lt;li&gt;cache content that is genuinely reusable&lt;/li&gt;
&lt;li&gt;block only when waiting is an intentional product choice&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once those decisions are protected by &lt;code&gt;instant()&lt;/code&gt; tests, navigation performance stops being something that can disappear unnoticed during the next refactor.&lt;/p&gt;

</description>
      <category>nextjs</category>
    </item>
    <item>
      <title>Implementing Postgres Row-Level Security in Next.js: The Drizzle Multi-Tenant Pattern</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Wed, 16 Sep 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/implementing-postgres-row-level-security-in-nextjs-the-drizzle-multi-tenant-pattern-428l</link>
      <guid>https://dev.to/u11d/implementing-postgres-row-level-security-in-nextjs-the-drizzle-multi-tenant-pattern-428l</guid>
      <description>&lt;p&gt;The first time we turned on Row-Level Security, nothing happened. Policies were in place, &lt;code&gt;ENABLE ROW LEVEL SECURITY&lt;/code&gt; had run, and every query still returned every tenant's rows. Took a minute to remember: we were connected as &lt;code&gt;postgres&lt;/code&gt;, the superuser, and superusers ignore RLS entirely. That one gotcha basically shaped the rest of this setup.&lt;/p&gt;

&lt;p&gt;The problem we were actually trying to solve is boring and everyone's had it: a route handler forgets &lt;code&gt;WHERE organization_id = ?&lt;/code&gt;, code review doesn't catch it because the query still "works," and Tenant A quietly gets to see Tenant B's data. Nobody notices until someone does.&lt;/p&gt;

&lt;p&gt;Our answer was to stop trusting application code to remember the filter and push isolation into Postgres itself with &lt;a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html" rel="noopener noreferrer"&gt;Row-Level Security&lt;/a&gt;. The pitch is simple: if a query forgets tenant scoping, it should get zero rows back, not someone else's data. Here's the setup we landed on — two DB roles, a &lt;code&gt;SET LOCAL&lt;/code&gt; session variable, a Next.js wrapper so route handlers don't have to think about any of this — plus the four things that actually bit us building it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two roles, two connections
&lt;/h2&gt;

&lt;p&gt;Back to that first surprise: &lt;strong&gt;superusers and table owners bypass RLS by default.&lt;/strong&gt; The &lt;a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html" rel="noopener noreferrer"&gt;docs&lt;/a&gt; say it plainly — superuser or &lt;code&gt;BYPASSRLS&lt;/code&gt;, policies don't apply, full stop. Table owners get the same pass unless you explicitly add &lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt;. If your app connects as &lt;code&gt;postgres&lt;/code&gt;, which is what basically every local Postgres setup defaults to, your carefully written policies are just decoration.&lt;/p&gt;

&lt;p&gt;So we run two roles now:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;RLS&lt;/th&gt;
&lt;th&gt;Used by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;postgres&lt;/code&gt; (superuser)&lt;/td&gt;
&lt;td&gt;Bypassed&lt;/td&gt;
&lt;td&gt;Auth library, admin routes, migrations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;app_user&lt;/code&gt; (non-superuser)&lt;/td&gt;
&lt;td&gt;Enforced&lt;/td&gt;
&lt;td&gt;All tenant-scoped queries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each gets its own pool, wrapped as two Drizzle instances:&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;// db.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;getDbAdmin&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;PostgresJsDatabase&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Schema&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="nx"&gt;_dbAdmin&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;adminConnectionConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// DATABASE_URL, superuser&lt;/span&gt;
      &lt;span class="na"&gt;max&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="na"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;application_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app-admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;_dbAdmin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;drizzle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;schema&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;_dbAdmin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getDb&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;PostgresJsDatabase&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Schema&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="nx"&gt;_db&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;appConnectionConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// APP_DATABASE_URL, app_user&lt;/span&gt;
      &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;application_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app-tenant&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;_db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;drizzle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;_db&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth flagging two things here. First, the lazy init isn't stylistic — Next.js runs module scope at build time, and if you open a connection pool at import time, &lt;code&gt;next build&lt;/code&gt; needs a live database to succeed. That bit us once in CI. Wrapping construction in a getter defers the actual connection until something queries it.&lt;/p&gt;

&lt;p&gt;We also wrap both in a &lt;code&gt;Proxy&lt;/code&gt; so call sites can still just import &lt;code&gt;db&lt;/code&gt; and use it like a normal object:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&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;Proxy&lt;/span&gt;&lt;span class="p"&gt;({}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;PostgresJsDatabase&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Schema&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prop&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="nb"&gt;Reflect&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="nf"&gt;getDb&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;prop&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;I won't pretend this is elegant. It works, and it means nobody importing &lt;code&gt;db&lt;/code&gt; has to know it's lazy underneath. Fine trade.&lt;/p&gt;

&lt;p&gt;Second thing: we set a distinct &lt;code&gt;application_name&lt;/code&gt; per pool. Costs nothing, and the first time something's connection-starved you'll be very glad &lt;code&gt;pg_stat_activity&lt;/code&gt; tells you exactly which pool is choking instead of guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The policy itself
&lt;/h2&gt;

&lt;p&gt;Every tenant table gets the same shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.organization_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.organization_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things matter more than they look like they should.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;true&lt;/code&gt; as the second argument to &lt;a href="https://www.postgresql.org/docs/current/functions-admin.html#FUNCTIONS-ADMIN-SET" rel="noopener noreferrer"&gt;&lt;code&gt;current_setting&lt;/code&gt;&lt;/a&gt; means "don't error if the setting's unset, just give me NULL." NULL compared to anything is NULL, and Postgres treats a NULL policy result as "exclude this row" — same as false. So a query that runs without tenant context isn't a crash and isn't a leak, it's just an empty table. Someone forgets to set context, they get a confusing empty result, they go debug it. Nobody's data went anywhere in the meantime. That's the property I actually care about here.&lt;/p&gt;

&lt;p&gt;And don't skip &lt;code&gt;WITH CHECK&lt;/code&gt;. It's easy to write the &lt;code&gt;USING&lt;/code&gt; clause, ship it, and feel done — but &lt;code&gt;USING&lt;/code&gt; only governs reads. Without &lt;code&gt;WITH CHECK&lt;/code&gt;, nothing stops a tenant from &lt;code&gt;INSERT&lt;/code&gt;ing or &lt;code&gt;UPDATE&lt;/code&gt;ing a row with someone else's &lt;code&gt;organization_id&lt;/code&gt; in it. We missed this on the first pass of one migration and caught it in review, which is exactly the kind of thing you don't want to catch in review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting context into the transaction: SET LOCAL
&lt;/h2&gt;

&lt;p&gt;Policies check &lt;code&gt;current_setting('app.organization_id')&lt;/code&gt;, so something has to set it per request. The obvious first attempt — plain &lt;code&gt;SET app.organization_id = '...'&lt;/code&gt; — is wrong, and it's wrong in a way that won't show up until you're under load. &lt;code&gt;SET&lt;/code&gt; is session-scoped, connections are pooled, and a session-scoped setting sticks around for whoever grabs that connection next. That's tenant bleed waiting to happen the moment your pool gets reused across requests, which it will.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.postgresql.org/docs/current/sql-set.html" rel="noopener noreferrer"&gt;&lt;code&gt;SET LOCAL&lt;/code&gt;&lt;/a&gt; is the fix — scoped to the current transaction, reverts automatically on commit or rollback. Open transaction, &lt;code&gt;SET LOCAL&lt;/code&gt;, run your queries, done, connection goes back to the pool clean.&lt;/p&gt;

&lt;p&gt;In Next.js we wrapped this into one function every tenant route goes through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withAuth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AuthenticatedHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;RouteContext&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// session lookup&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&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;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;setOrgContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationId&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;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;castTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Route handlers never see any of this. They just get a &lt;code&gt;tx&lt;/code&gt; that's already scoped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withAuth&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;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&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;projects&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// only this org's rows, guaranteed&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&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;No &lt;code&gt;WHERE organizationId = ?&lt;/code&gt; anywhere in that query, and it's still safe. We keep the explicit filters in most places anyway — belt and suspenders, and it documents intent for the next person reading the code — but the point stands: if you forgot it, RLS still has you.&lt;/p&gt;

&lt;p&gt;Server components got a matching helper since they're not going through route handlers at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withOrgDb&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;organizationId&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;callback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DbInstance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getDb&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;transaction&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;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;setOrgContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationId&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;callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;castTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&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;h2&gt;
  
  
  Gotcha #1: you can't parameterize SET
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;setOrgContext&lt;/code&gt; looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;UUID_RE&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;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{8}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9a-f&lt;/span&gt;&lt;span class="se"&gt;]{12}&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setOrgContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DrizzleTx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;UUID_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid organization ID format&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;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`SET LOCAL app.organization_id = '&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;organizationId&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;sql.raw()&lt;/code&gt; should bother you a little — it bothered me too, first time I wrote it. Here's the thing though: &lt;code&gt;SET&lt;/code&gt; is a utility command, not a regular statement, and utility commands in Postgres just don't take &lt;code&gt;$1&lt;/code&gt;-style parameters. You cannot write &lt;code&gt;SET LOCAL app.organization_id = $1&lt;/code&gt;. It'll error. You've got two real options — &lt;code&gt;set_config('app.organization_id', $1, true)&lt;/code&gt;, which is a normal function call and does accept a parameter, or raw string interpolation gated behind strict validation. We went with the second, gated on a UUID regex: the value either looks exactly like a UUID or the function throws before it ever touches SQL. I still don't love it stylistically, but there's no injection surface left once that regex has to match.&lt;/p&gt;

&lt;p&gt;Point either way: you're going to hit this wall the first time you try to parameterize a &lt;code&gt;SET&lt;/code&gt;, and it's not a bug in your code, it's just how the command works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #2: Drizzle's transaction type doesn't match its client type
&lt;/h2&gt;

&lt;p&gt;Drizzle's &lt;a href="https://orm.drizzle.team/docs/transactions" rel="noopener noreferrer"&gt;transaction callback&lt;/a&gt; hands you a &lt;code&gt;PostgresJsTransaction&lt;/code&gt;, and that type is not structurally the same as &lt;code&gt;PostgresJsDatabase&lt;/code&gt; — even though every method you'd actually call (&lt;code&gt;select&lt;/code&gt;, &lt;code&gt;insert&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;delete&lt;/code&gt;) is identical between them. If &lt;code&gt;AuthContext.tx&lt;/code&gt; gets typed as the transaction class, every shared data function downstream now needs that same internal type, which means importing a class Drizzle never really meant for you to reach into.&lt;/p&gt;

&lt;p&gt;We just centralized the cast once and moved on:&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;// Extract the tx type without importing internal Drizzle classes&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;DrizzleTx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Parameters&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Parameters&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DbInstance&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transaction&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;castTx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DrizzleTx&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;DbInstance&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;tx&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;DbInstance&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;Now shared data-layer code just takes a &lt;code&gt;DbInstance&lt;/code&gt; and doesn't care if it's the root client, the admin client, or a live transaction. One cast, one comment explaining why, easy to delete later if Drizzle ever unifies the types properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #3: don't let a stream hold your transaction
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;withAuth&lt;/code&gt; holds a transaction — and the pooled connection under it — for as long as the request runs. For a JSON endpoint that returns in 40ms, fine. For a Server-Sent Events endpoint that stays open for twenty minutes, that's a problem: with a pool of 4, three people leaving a dashboard tab open exhausts it and everyone else starts timing out.&lt;/p&gt;

&lt;p&gt;We learned this the hard way and split off a separate wrapper for streaming routes — it authenticates, but deliberately never opens a transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withAuthStream&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;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;organizationId&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="c1"&gt;// Short transaction for the initial state read&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withOrgDb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;organizationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&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;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&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;jobs&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jobId&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;stream&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;ReadableStream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="cm"&gt;/* ...on each event, use withOrgDb() again for any query... */&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/event-stream&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule we now follow: RLS context only lives as long as a transaction, so keep the transaction as short as the query needs — never as long as the request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #4: knowing which connection you're on
&lt;/h2&gt;

&lt;p&gt;Two Drizzle instances means the failure mode shifted from "forgot a WHERE clause" to "grabbed the wrong client." We ended up needing a cheat sheet:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Connection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tenant API route&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tx&lt;/code&gt; from &lt;code&gt;withAuth&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSE route&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;withOrgDb()&lt;/code&gt; per query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server component, tenant data&lt;/td&gt;
&lt;td&gt;&lt;code&gt;withOrgDb()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server component, auth tables only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dbAdmin&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin route&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dbAdmin&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth library internals&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dbAdmin&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migrations / background workers&lt;/td&gt;
&lt;td&gt;superuser&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reassuring part: the mistake in the safe direction fails loudly. Query tenant tables through &lt;code&gt;db&lt;/code&gt; with no context set, and you get zero rows — a visibly broken feature in dev, not a security incident. Using &lt;code&gt;dbAdmin&lt;/code&gt; where you meant tenant scoping is the dangerous direction, and that's the one worth actually watching for in review. At least it's greppable.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>drizzle</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Postgres LISTEN/NOTIFY in Production: Safe Reconnects &amp; SSE</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Fri, 14 Aug 2026 07:24:09 +0000</pubDate>
      <link>https://dev.to/u11d/postgres-listennotify-in-production-safe-reconnects-sse-29hd</link>
      <guid>https://dev.to/u11d/postgres-listennotify-in-production-safe-reconnects-sse-29hd</guid>
      <description>&lt;p&gt;If your app needs to push live updates to connected clients — a job progress bar, a balance that changes when a background worker finishes, a "this record was deleted elsewhere" toast — the reflex is to reach for Redis Pub/Sub or a message broker. But if you already run PostgreSQL, it ships with a pub/sub primitive built in: &lt;code&gt;LISTEN&lt;/code&gt; / &lt;code&gt;NOTIFY&lt;/code&gt;. No extra infrastructure, no extra failure domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is Postgres LISTEN/NOTIFY?&lt;/strong&gt; It's a built-in pub/sub mechanism: one session runs &lt;code&gt;pg_notify(channel, payload)&lt;/code&gt; inside a transaction, and any other session that has issued &lt;code&gt;LISTEN &amp;lt;channel&amp;gt;&lt;/code&gt; on a persistent connection receives the payload once that transaction commits. There's no persistence and no delivery guarantee — it's a live signal, not a queue — so it fits best as a "something changed, go re-check" nudge rather than a system of record.&lt;/p&gt;

&lt;p&gt;This post walks through the implementation we actually run in production: a single shared LISTEN connection multiplexing many logical channels, wired into per-client Server-Sent Events (SSE) streams. Along the way: the reconnect logic and the race conditions that come with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Postgres LISTEN/NOTIFY works
&lt;/h2&gt;

&lt;p&gt;Postgres gives you two SQL-level building blocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_notify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'job_status_123'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{"status":"done"}'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and, on a &lt;strong&gt;persistent&lt;/strong&gt; connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;LISTEN&lt;/span&gt; &lt;span class="n"&gt;job_status_123&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any session that has issued &lt;code&gt;LISTEN&lt;/code&gt; on a channel receives the payload once the transaction that ran &lt;code&gt;pg_notify&lt;/code&gt; commits, not the instant the call executes. Multiple identical notifications on the same channel/payload fired within one transaction get collapsed into one delivery. Several constraints shape everything that follows (see the Postgres NOTIFY reference for the authoritative spec):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Payload is capped at 8000 bytes. Fine for status pings, not for shipping large blobs.&lt;/li&gt;
&lt;li&gt;Channel names are Postgres identifiers, truncated at 63 characters. Patterns like &lt;code&gt;job_status_&amp;lt;uuid&amp;gt;&lt;/code&gt; need to fit under that limit or get silently truncated and collide.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No persistence, no delivery guarantee.&lt;/strong&gt; If nobody is listening when &lt;code&gt;pg_notify&lt;/code&gt; fires, the message is gone. This is a live signal, not a queue: any client relying on it needs a way to independently reconcile state, e.g. re-fetch from the DB.&lt;/li&gt;
&lt;li&gt;The notification queue itself is bounded (8GB by default, per the Postgres async messaging docs). If a listening session sits idle inside an open transaction, it stops draining the queue for everyone, and &lt;code&gt;pg_notify&lt;/code&gt; starts erroring once the queue fills. Keep LISTEN sessions out of long-running transactions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because &lt;code&gt;LISTEN&lt;/code&gt; requires a session-scoped, long-lived connection, and most apps talk to Postgres through a connection pool, you can't just &lt;code&gt;LISTEN&lt;/code&gt; from your normal query pool. Pooled connections get recycled and your subscription vanishes with them. You need one connection dedicated to listening, held open for the life of the process.&lt;/p&gt;

&lt;p&gt;This also rules out transaction-mode poolers like pgbouncer or RDS Proxy for the LISTEN connection specifically. They can hand your session's underlying socket to a different client between transactions, which silently kills the subscription. The dedicated LISTEN connection needs to bypass the pooler and go straight to Postgres. If you run multiple app instances, each one holds its own LISTEN connection and each receives every NOTIFY independently, which is usually what you want: every instance can push updates to its own locally-connected clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  One connection, many channels
&lt;/h2&gt;

&lt;p&gt;Rather than open a Postgres connection per subscriber, a single physical &lt;code&gt;LISTEN&lt;/code&gt; connection can multiplex an arbitrary number of logical channels. In-process, that's just a map from channel name to a set of callbacks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;NotificationHub&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ReturnType&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;postgres&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&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;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;listeners&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Channel&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="nx"&gt;MessageCallback&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;unlistenFns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connection itself is configured to never idle out and to identify itself for observability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nf"&gt;getAppConnectionConfig&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;max&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="na"&gt;idle_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;connect_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;application_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app-listen&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;onclose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cm"&gt;/* ... */&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pool is deliberately capped at one connection: it exists purely to host a dedicated LISTEN session, not to serve queries. Mixing it into a general-purpose pool would mean losing subscriptions every time the pool recycles a connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling reconnects: capped exponential backoff
&lt;/h2&gt;

&lt;p&gt;A long-lived TCP connection to a database will eventually drop: network blips, database failover, load balancer idle timeouts. The interesting design question isn't "will it disconnect" but "what happens to every registered channel when it does."&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="nx"&gt;onclose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;dbLogger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LISTEN connection closed unexpectedly&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;disconnected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduleReconnect&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;Reconnect uses a capped exponential backoff so a flapping database doesn't get hammered with reconnect attempts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;RECONNECT_DELAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;scheduleReconnect&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;closed&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectTimer&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delayIndex&lt;/span&gt; &lt;span class="o"&gt;=&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectAttempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RECONNECT_DELAYS&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="mi"&gt;1&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;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RECONNECT_DELAYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;delayIndex&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectAttempt&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectTimer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectTimer&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;void&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;delay&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;reconnectAttempt&lt;/code&gt; resets to 0 on a successful &lt;code&gt;connect()&lt;/code&gt;, so backoff only escalates across consecutive failures. A single flaky reconnect doesn't leave future reconnects artificially delayed.&lt;/p&gt;

&lt;p&gt;On reconnect, every channel that had at least one active subscriber gets re-&lt;code&gt;LISTEN&lt;/code&gt;ed from scratch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listeners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&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;failedChannels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;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;channel&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listenToChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;failedChannels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failedChannels&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;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;disconnected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduleReconnect&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the all-or-nothing treatment: if even one channel fails to re-&lt;code&gt;LISTEN&lt;/code&gt;, the whole reconnect is considered failed and retried. A hub that's "half connected," silently missing notifications on some channels, is worse than one that's visibly disconnected and retrying. At least then the failure isn't invisible to callers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unsubscribe race, solved by ordering, not locking
&lt;/h2&gt;

&lt;p&gt;Multiple parts of an app can subscribe to the same channel concurrently (two browser tabs watching the same job, for instance). The tricky moment is when the &lt;em&gt;last&lt;/em&gt; subscriber for a channel unsubscribes at the same time a &lt;em&gt;new&lt;/em&gt; subscriber for that same channel is arriving:&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="nx"&gt;unsubscribe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;channel&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;channels&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;callbacks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listeners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&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;callbacks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onMessage&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;callbacks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listeners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&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;unlisten&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unlistenFns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;// Delete before awaiting so a concurrent subscribe() for this&lt;/span&gt;
      &lt;span class="c1"&gt;// channel calls listenToChannel() again. PostgreSQL silently&lt;/span&gt;
      &lt;span class="c1"&gt;// ignores a duplicate LISTEN on the same connection, so the race&lt;/span&gt;
      &lt;span class="c1"&gt;// is harmless — the hub will have an active LISTEN either way.&lt;/span&gt;
      &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unlistenFns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&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;unlisten&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeoutUnlisten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unlisten&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* timed out or connection gone — safe to ignore */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The naive version would &lt;code&gt;await UNLISTEN&lt;/code&gt; first, then clean up bookkeeping. But between "decide to unlisten" and "UNLISTEN finishes," a new subscriber could arrive, see the channel already has a bookkeeping entry, and skip re-&lt;code&gt;LISTEN&lt;/code&gt;ing. That leaves the hub with no active &lt;code&gt;LISTEN&lt;/code&gt; for a channel it thinks it's subscribed to.&lt;/p&gt;

&lt;p&gt;By deleting the map entry &lt;em&gt;before&lt;/em&gt; awaiting &lt;code&gt;UNLISTEN&lt;/code&gt;, a concurrent &lt;code&gt;subscribe()&lt;/code&gt; call always sees "no entry" and issues its own &lt;code&gt;LISTEN&lt;/code&gt; unconditionally. Postgres treats a duplicate &lt;code&gt;LISTEN&lt;/code&gt; on the same session as a no-op, so the worst case is two harmless &lt;code&gt;LISTEN&lt;/code&gt; calls in flight instead of a silently dropped subscription. When you can't easily lock across an async boundary, look for an operation idempotent enough to make the race safe by construction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Callers should never worry about connection state
&lt;/h2&gt;

&lt;p&gt;The public &lt;code&gt;subscribe()&lt;/code&gt; API registers callbacks unconditionally, regardless of whether the underlying Postgres connection is currently up. It only rejects if an opportunistic &lt;code&gt;LISTEN&lt;/code&gt; call fails outright while attempting an already-connected session, and that case is recovered by the reconnect logic on its next attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Channel&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MessageCallback&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Subscription&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Register callbacks regardless of connection state&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;channel&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;callbacks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listeners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&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;callbacks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;callbacks&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;listeners&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;callbacks&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;callbacks&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;onMessage&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&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;channel&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;channels&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listenToChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// no-op if already LISTENing&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;disconnected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connecting&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reconnectTimer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;unsubscribe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Callbacks are registered in memory first; the actual &lt;code&gt;LISTEN&lt;/code&gt; happens opportunistically if connected, or gets picked up automatically on the next successful reconnect. Every consumer of the hub stays decoupled from the connection's health. They just subscribe, and messages show up when the pipe is open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into Server-Sent Events
&lt;/h2&gt;

&lt;p&gt;The hub by itself is transport-agnostic; a thin SSE layer turns it into per-client streams with keep-alives and cleanup on disconnect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&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;ReadableStream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;subscription&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Subscription&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&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;onAbort&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;options&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;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;abort&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onAbort&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;keepAliveInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;safeEnqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`: ping\n\n`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nx"&gt;keepAliveMs&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;init&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;safeEnqueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscribe&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;isClosed&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onMessage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="nx"&gt;unsubscribeState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;onHubStateChange&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;safeEnqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="s2"&gt;`data: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&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;info&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;liveUpdates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;connected&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})}&lt;/span&gt;&lt;span class="s2"&gt;\n\n`&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;subscription&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;subscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="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;isClosed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="nf"&gt;onMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;safeEnqueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onAfterSubscribe&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;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onAfterSubscribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;safeEnqueue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&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;init&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;cleanup&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;cancel&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;cleanupFn&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;Two details worth calling out.&lt;/p&gt;

&lt;p&gt;The hub's connection state is surfaced to the client. When &lt;code&gt;onHubStateChange&lt;/code&gt; reports &lt;code&gt;"disconnected"&lt;/code&gt;, an &lt;code&gt;{"type":"info","liveUpdates":false}&lt;/code&gt; frame goes out over SSE so the UI can show "reconnecting..." instead of silently going stale. Since NOTIFY gives no delivery guarantee, telling the client when it might be missing updates is almost as important as delivering the updates themselves.&lt;/p&gt;

&lt;p&gt;There's also a read-then-subscribe race window. &lt;code&gt;onStart&lt;/code&gt; typically does an initial DB read to send current state, and only afterwards does the code call &lt;code&gt;subscribe()&lt;/code&gt; to start receiving live updates. If a &lt;code&gt;NOTIFY&lt;/code&gt; fires in the gap between that read and the &lt;code&gt;LISTEN&lt;/code&gt; actually being established, it's lost for good: the classic "check-then-watch" race in any pub/sub system. The &lt;code&gt;onAfterSubscribe&lt;/code&gt; hook closes that window. After the subscription is confirmed active, it re-checks state one more time, so any update that landed in the gap gets picked up on the second read instead of being silently missed. The client-side handler needs to tolerate receiving the same update twice (once from the catch-up read, once from a live NOTIFY that arrives moments later). Treat updates as idempotent, not as a strictly-once stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this pattern fits
&lt;/h2&gt;

&lt;p&gt;Postgres LISTEN/NOTIFY works well when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Update volume is modest (dashboards, job status, per-user notifications), not a high-throughput event bus.&lt;/li&gt;
&lt;li&gt;Clients treat NOTIFY as a nudge to re-sync, not as the sole source of truth. Always have a path to reconstruct state via a normal query if a notification is missed.&lt;/li&gt;
&lt;li&gt;You want one fewer piece of infrastructure to run and monitor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's the wrong tool when you need guaranteed delivery, durable queues, replay, or very high fan-out. That's what a real message broker is for. But for "tell connected clients something changed," Postgres has had a message bus built in the whole time.&lt;/p&gt;

</description>
      <category>postgres</category>
    </item>
    <item>
      <title>Faster Geospatial Animations with PMTiles: Multi-Layer MVT Time Series in OpenLayers</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Wed, 08 Jul 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/faster-geospatial-animations-with-pmtiles-multi-layer-mvt-time-series-in-openlayers-30d6</link>
      <guid>https://dev.to/u11d/faster-geospatial-animations-with-pmtiles-multi-layer-mvt-time-series-in-openlayers-30d6</guid>
      <description>&lt;p&gt;Most people try to animate time series by stacking separate tile sources or flipping CSS styles mid-frame. That causes flicker and a surprising amount of wasted CPU/GPU work. The core takeaway is simple: bake your timesteps into a single vector-tile archive (PMTiles) with per-timestep named layers, load it once, and drive animation by re-styling already-decoded tiles.&lt;/p&gt;

&lt;p&gt;I tested this with OpenLayers and noticed the difference right away: playback became smoother and far less CPU-bound once tiles were pre-baked and cached. The hard part is server-side — how you generate, package, and serve tiles matters more than which client library you pick.&lt;/p&gt;

&lt;p&gt;When you animate a map by switching between separate tile sources, each frame causes one of two expensive operations: a network fetch of new tiles, or client-side re-decoding and re-styling. Baking timesteps into one archive avoids that by turning each frame into a cheap style re-evaluation on already-decoded features.&lt;/p&gt;




&lt;h2&gt;
  
  
  The architecture: single archive, dynamic layers
&lt;/h2&gt;

&lt;p&gt;MVT (Mapbox Vector Tile) is a compact protobuf-based vector tile format that encodes geometry and attributes per z/x/y tile. PMTiles packages MVT tiles into a single file for efficient distribution and partial HTTP range retrieval.&lt;/p&gt;

&lt;p&gt;The key workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Server side&lt;/strong&gt; — each timestep is a separate GeoJSON file. &lt;code&gt;tippecanoe&lt;/code&gt; joins them into one PMTiles archive, giving each timestep its own named MVT layer (&lt;code&gt;timestep_00&lt;/code&gt;, &lt;code&gt;timestep_01&lt;/code&gt;, …).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client side&lt;/strong&gt; — one &lt;code&gt;VectorTileSource&lt;/code&gt; loads the archive once. One &lt;code&gt;VectorTileLayer&lt;/code&gt; uses a style function that reads a mutable &lt;code&gt;currentLayerName&lt;/code&gt; variable. Advancing a frame means: update the variable, call &lt;code&gt;layer.changed()&lt;/code&gt;. OpenLayers re-runs the style function on the already-decoded tile features and repaints — no network request, no re-decode.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Complete working example
&lt;/h2&gt;

&lt;p&gt;!animation.gif&lt;/p&gt;

&lt;p&gt;This section contains everything needed to reproduce the demo from scratch: 11 per-timestep GeoJSON files, a tile generation script, and the client HTML.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Per-timestep GeoJSON files
&lt;/h3&gt;

&lt;p&gt;Create 11 files, one per timestep. Each contains a single large polygon with a &lt;code&gt;value&lt;/code&gt; property. The temporal identity comes from the file name (and the MVT layer name tippecanoe will assign), not from a property on the feature.&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="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;0 10&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;pad&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%02d"&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;i &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"square_t&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;pad&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.geojson"&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
{
  "type": "FeatureCollection",
  "features": [{
    "type": "Feature",
    "geometry": {
      "type": "Polygon",
      "coordinates": [[[-60,-50],[60,-50],[60,50],[-60,50],[-60,-50]]]
    },
    "properties": { "value": &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;value&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt; }
  }]
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces &lt;code&gt;square_t00.geojson&lt;/code&gt; (value=0) through &lt;code&gt;square_t10.geojson&lt;/code&gt; (value=100).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — Generate PMTiles with named layers (&lt;code&gt;generate-tiles.sh&lt;/code&gt;)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;SCRIPT_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASH_SOURCE&lt;/span&gt;&lt;span class="p"&gt;[0]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

tippecanoe &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_00:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t00.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_01:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t01.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_02:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t02.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_03:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t03.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_04:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t04.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_05:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t05.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_06:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t06.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_07:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t07.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_08:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t08.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_09:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t09.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--named-layer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;timestep_10:&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square_t10.geojson"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCRIPT_DIR&lt;/span&gt;&lt;span class="s2"&gt;/square.pmtiles"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-z&lt;/span&gt; 8 &lt;span class="nt"&gt;-Z&lt;/span&gt; 0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-simplification&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-tile-size-limit&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-feature-limit&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run with: &lt;code&gt;bash generate-tiles.sh&lt;/code&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Start the server
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx serve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4 — Client animation (&lt;code&gt;index.html&lt;/code&gt;)
&lt;/h3&gt;

&lt;p&gt;The full self-contained HTML file. No build step, no npm install — all dependencies come from &lt;code&gt;esm.sh&lt;/code&gt;, which rewrites bare module specifiers (like &lt;code&gt;pbf&lt;/code&gt; imported inside &lt;code&gt;ol/format/MVT&lt;/code&gt;) to absolute CDN URLs that the browser can resolve.&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="cp"&gt;&amp;lt;!DOCTYPE html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;html&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"en"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;charset=&lt;/span&gt;&lt;span class="s"&gt;"UTF-8"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"viewport"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"width=device-width, initial-scale=1.0"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;MVT Animation&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&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;href=&lt;/span&gt;&lt;span class="s"&gt;"&amp;lt;https://cdn.jsdelivr.net/npm/ol@10.9.0/ol.css&amp;gt;"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;style&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;box-sizing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;border-box&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;monospace&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#111&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#eee&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
           &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;flex-direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100vh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#map&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#hud&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt; &lt;span class="m"&gt;20px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#1a1a1a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#333&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;24px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#hud&lt;/span&gt; &lt;span class="nt"&gt;label&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;13px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#aaa&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#hud&lt;/span&gt; &lt;span class="nt"&gt;span&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;16px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bold&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#timestep-val&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#7af&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#value-val&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fa7&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#color-swatch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;28px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;28px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                    &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#555&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inline-block&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#progress-bar&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;height&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="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#333&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;overflow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#progress-fill&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="nb"&gt;right&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#00f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#f00&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#controls&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;gap&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;button&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#333&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#eee&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="m"&gt;#555&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
             &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5px&lt;/span&gt; &lt;span class="m"&gt;14px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;pointer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;13px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="nd"&gt;:hover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#444&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#loading&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;absolute&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;inset&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;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
               &lt;span class="nl"&gt;justify-content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rgba&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="m"&gt;0&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="m"&gt;0.55&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#fff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
               &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15px&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="nl"&gt;z-index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;999&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;#loading&lt;/span&gt;&lt;span class="nc"&gt;.hidden&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/style&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"map"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"loading"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Loading tiles…&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"hud"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&amp;gt;&amp;lt;label&amp;gt;&lt;/span&gt;Timestep &lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"timestep-val"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&amp;gt;&amp;lt;label&amp;gt;&lt;/span&gt;Value &lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"value-val"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"display:flex;align-items:center;gap:8px;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;label&amp;gt;&lt;/span&gt;Color &lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"color-swatch"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"progress-bar"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"progress-fill"&lt;/span&gt; &lt;span class="na"&gt;style=&lt;/span&gt;&lt;span class="s"&gt;"width:0%"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"controls"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"btn-playpause"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Pause&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;button&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"btn-reset"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Reset&lt;span class="nt"&gt;&amp;lt;/button&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nb"&gt;Map&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;&amp;lt;https://esm.sh/ol@10.9.0/Map&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;View&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;&amp;lt;https://esm.sh/ol@10.9.0/View&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;TileLayer&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;&amp;lt;https://esm.sh/ol@10.9.0/layer/Tile&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OSM&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;&amp;lt;https://esm.sh/ol@10.9.0/source/OSM&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;VectorTileLayer&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;&amp;lt;https://esm.sh/ol@10.9.0/layer/VectorTile&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;VectorTileSource&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;&amp;lt;https://esm.sh/ol@10.9.0/source/VectorTile&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;MVT&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;&amp;lt;https://esm.sh/ol@10.9.0/format/MVT&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Style&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;&amp;lt;https://esm.sh/ol@10.9.0/style/Style&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Fill&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;&amp;lt;https://esm.sh/ol@10.9.0/style/Fill&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stroke&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;&amp;lt;https://esm.sh/ol@10.9.0/style/Stroke&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fromLonLat&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;&amp;lt;https://esm.sh/ol@10.9.0/proj&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PMTiles&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;&amp;lt;https://esm.sh/pmtiles@4.4.1&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep_00&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;timestep_01&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;timestep_02&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;timestep_03&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;timestep_04&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;timestep_05&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;timestep_06&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;timestep_07&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;timestep_08&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;timestep_09&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;timestep_10&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;VALUES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&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;STYLES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Style&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Fill&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`rgb(&lt;/span&gt;&lt;span class="p"&gt;${&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;,0,&lt;/span&gt;&lt;span class="p"&gt;${&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="na"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stroke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#fff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;archive&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;PMTiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;square.pmtiles&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;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;VectorTileSource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MVT&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;square.pmtiles&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;maxZoom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;squareLayer&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;VectorTileLayer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;feature&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;layer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;currentLayerIndex&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
          &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;STYLES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;currentLayerIndex&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="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;map&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;Map&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;map&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;layers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TileLayer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OSM&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="nx"&gt;squareLayer&lt;/span&gt; &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;view&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;center&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;fromLonLat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="na"&gt;zoom&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FRAME_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&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;animate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onFrame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;intervalId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&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="nx"&gt;currentLayerIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentLayerIndex&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="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;squareLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nf"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
        &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&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="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;FRAME_MS&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="nf"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;intervalId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;intervalId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&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="nx"&gt;currentLayerIndex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currentLayerIndex&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="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="nx"&gt;squareLayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;changed&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
          &lt;span class="nf"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
          &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&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="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;frames&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;FRAME_MS&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="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;timestepEl&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timestep-val&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;valueEl&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;value-val&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;swatchEl&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;color-swatch&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;progressFill&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;progress-fill&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;onFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;layerName&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;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;layerName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;timestepEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;layerName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;valueEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;VALUES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="nx"&gt;swatchEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;background&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;STYLES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;getFill&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getColor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;progressFill&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&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;i&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LAYER_NAMES&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&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="nf"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loadingEl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;player&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;let&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;startWhenReady&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;started&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;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;loadingEl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hidden&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;player&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;LAYER_NAMES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onFrame&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tileloadend&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startWhenReady&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rendercomplete&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startWhenReady&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startWhenReady&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;playing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;btn-playpause&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&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;e&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="nx"&gt;player&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;playing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;playing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;playing&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pause&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;Play&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;playing&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pause&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;btn-reset&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&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="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="nx"&gt;player&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;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="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;playing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;playing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;btn-playpause&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pause&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&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="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Running the full setup
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Install tippecanoe (macOS)&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;tippecanoe

&lt;span class="c"&gt;# 2. Create the 11 per-timestep GeoJSON files&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;0 10&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;pad&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"%02d"&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="si"&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="k"&gt;$((&lt;/span&gt;i &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[-60,-50],[60,-50],[60,50],[-60,50],[-60,-50]]]},"properties":{"value":'&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="s1"&gt;'}}]}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; square_t&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;pad&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;.geojson
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# 3. Generate the PMTiles archive&lt;/span&gt;
bash generate-tiles.sh

&lt;span class="c"&gt;# 4. Start the server&lt;/span&gt;
npx serve

&lt;span class="c"&gt;# 5. Open the demo&lt;/span&gt;
open &amp;lt;http://localhost:3000/index.html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Conclusion: paint time, not flip it
&lt;/h2&gt;

&lt;p&gt;Don't stack separate tile sources for time-series animation. Bake timesteps into a single PMTiles archive as named MVT layers, load it once, and drive playback by updating a closure variable and calling &lt;code&gt;layer.changed()&lt;/code&gt;. Serve the archive with &lt;code&gt;npx serve&lt;/code&gt;. Start animation only after the first tile arrives.&lt;/p&gt;

&lt;p&gt;The result: smooth playback from first load, no flicker on hard refresh, no blank map with cache disabled.&lt;/p&gt;

</description>
      <category>gis</category>
      <category>openlayers</category>
      <category>datavisualization</category>
      <category>geospatial</category>
    </item>
    <item>
      <title>Next.js 16 Caching for E-Commerce: Cache Components, use cache, revalidateTag, and Fresh Product Data</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Tue, 02 Jun 2026 22:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/nextjs-16-caching-for-e-commerce-cache-components-use-cache-revalidatetag-and-fresh-product-4ik1</link>
      <guid>https://dev.to/u11d/nextjs-16-caching-for-e-commerce-cache-components-use-cache-revalidatetag-and-fresh-product-4ik1</guid>
      <description>&lt;p&gt;Caching in e-commerce is never just about speed. A fast storefront is useful only if it still shows the right price, the correct stock level, and the right experience for the current customer.&lt;/p&gt;

&lt;p&gt;That is why caching in a Next.js storefront can be deceptively hard. Some data should be shared broadly and kept warm for SEO and performance. Some data should be refreshed often. Some should never be shared between users at all.&lt;/p&gt;

&lt;p&gt;Next.js 16 gives teams a much clearer toolbox for solving this problem with Cache Components, &lt;code&gt;use cache&lt;/code&gt;, tag-based invalidation, and explicit cache lifetime controls. Used properly, these features let you keep pages fast without drifting into stale commerce data.&lt;/p&gt;

&lt;p&gt;In this guide, I will walk through a practical way to think about caching in a modern storefront and show how to combine &lt;code&gt;use cache&lt;/code&gt;, &lt;code&gt;cacheLife&lt;/code&gt;, and &lt;code&gt;revalidateTag&lt;/code&gt; for real e-commerce use cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Caching Is Harder in E-Commerce Than in a Typical Content Site
&lt;/h2&gt;

&lt;p&gt;On a standard marketing site, most content changes infrequently. If a page is cached for a few minutes or even a few hours, the business impact is usually negligible.&lt;/p&gt;

&lt;p&gt;Commerce systems work differently.&lt;/p&gt;

&lt;p&gt;The same product page may contain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stable product descriptions and category copy&lt;/li&gt;
&lt;li&gt;semi-dynamic data such as price, availability, shipping estimates, or promotion labels&lt;/li&gt;
&lt;li&gt;private data such as cart state, recently viewed items, or customer-specific pricing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treating all of that data the same way leads to one of two bad outcomes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You cache too aggressively and serve stale prices or availability.&lt;/li&gt;
&lt;li&gt;You disable caching everywhere and lose the performance benefits that help SEO and conversion.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The better approach is to split your data by volatility and audience.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Cache Boundaries That Matter Most
&lt;/h2&gt;

&lt;p&gt;For most commerce projects, the cleanest mental model is to divide data into three groups.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Stable catalog content
&lt;/h3&gt;

&lt;p&gt;This is the part of the page that usually changes only when content editors or merchandisers update the catalog.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;product title and description&lt;/li&gt;
&lt;li&gt;brand information&lt;/li&gt;
&lt;li&gt;CMS blocks, FAQs, and long-form copy&lt;/li&gt;
&lt;li&gt;category landing page copy&lt;/li&gt;
&lt;li&gt;SEO metadata&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the best candidate for shared caching because it improves performance for everyone and is usually safe to invalidate on demand when content changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Shared but fast-changing commerce data
&lt;/h3&gt;

&lt;p&gt;This data is still shared between many users, but it changes more often and has stronger operational impact.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;current price for a product and region&lt;/li&gt;
&lt;li&gt;inventory status&lt;/li&gt;
&lt;li&gt;campaign badges&lt;/li&gt;
&lt;li&gt;delivery promises&lt;/li&gt;
&lt;li&gt;low-stock signals&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This layer often benefits from a shorter cache lifetime and explicit invalidation.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. User-specific or session-specific data
&lt;/h3&gt;

&lt;p&gt;This is the data that should not be globally cached.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cart contents&lt;/li&gt;
&lt;li&gt;logged-in customer state&lt;/li&gt;
&lt;li&gt;B2B contract pricing&lt;/li&gt;
&lt;li&gt;personalized recommendations&lt;/li&gt;
&lt;li&gt;account-specific discounts&lt;/li&gt;
&lt;li&gt;checkout calculations tied to the active session&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where teams most often make expensive mistakes. If the output depends on the current user, you need to keep that boundary private.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Next.js 16 Changes in Practice
&lt;/h2&gt;

&lt;p&gt;The major advantage of Next.js 16 is not just that it caches. It is that caching becomes more explicit.&lt;/p&gt;

&lt;p&gt;Instead of treating an entire route as fully static or fully dynamic, you can choose more precise boundaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;cache a component or function with &lt;code&gt;use cache&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;assign cache tags to data and invalidate them later&lt;/li&gt;
&lt;li&gt;control lifetime with &lt;code&gt;cacheLife&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;keep per-user data private where needed&lt;/li&gt;
&lt;li&gt;stream dynamic parts separately instead of forcing the entire page into request-time rendering&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For e-commerce storefronts, that means you can serve a fast shell and still keep critical parts fresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Practical Storefront Strategy
&lt;/h2&gt;

&lt;p&gt;Let us look at a product page. In a typical headless stack, product content might come from Medusa or another commerce backend, while editorial content may come from a CMS.&lt;/p&gt;

&lt;p&gt;The important point is not the backend itself. The important point is how you cache each layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cache the stable product content
&lt;/h3&gt;

&lt;p&gt;If product descriptions and media do not change every minute, cache them explicitly and tag them so they can be invalidated when merchandisers update the catalog.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cacheTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getProduct&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

  &lt;span class="nf"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`product:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COMMERCE_API_URL&lt;/span&gt;&lt;span class="p"&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;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to load product&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a good fit for data that should be broadly reusable and revalidated only when a product actually changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use a shorter cache boundary for price and stock
&lt;/h3&gt;

&lt;p&gt;Price and stock need more care. In some stores, they can still be shared safely for a short period. In others, especially B2B or highly promotional environments, they may need user-level or request-level handling.&lt;/p&gt;

&lt;p&gt;For shared price or inventory data, a short-lived cache can be a sensible compromise.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cacheTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&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;getOffer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;regionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

  &lt;span class="nf"&gt;cacheTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`offer:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;regionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;COMMERCE_API_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/offers/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?region=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;regionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to load offer&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here the offer can be shared, but only briefly. That is often enough to reduce backend pressure while still keeping the storefront operationally safe.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep customer-specific data out of the shared cache
&lt;/h3&gt;

&lt;p&gt;If pricing, recommendations, or entitlements depend on the active customer, do not treat them as generic shared data.&lt;/p&gt;

&lt;p&gt;This applies especially to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;B2B price lists&lt;/li&gt;
&lt;li&gt;customer group discounts&lt;/li&gt;
&lt;li&gt;partner-only catalog visibility&lt;/li&gt;
&lt;li&gt;personalized recommendation widgets&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At that point, a private boundary or request-time fetching becomes more appropriate than a shared cache.&lt;/p&gt;

&lt;p&gt;The principle is simple: if another customer must never see the same output, that data should not live in a shared cache.&lt;/p&gt;

&lt;p&gt;If you do want to cache user-specific data briefly to avoid repeating the same work during a session, keep that cache private instead of shared.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cacheLife&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getRecommendations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache: private&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

  &lt;span class="nf"&gt;cacheLife&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RECOMMENDATION_API_URL&lt;/span&gt;&lt;span class="p"&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;productId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/recommendations?customer=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to load recommendations&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That keeps the result bounded to the current user context instead of letting it leak into global storefront output. It is also worth noting that&amp;nbsp;&lt;code&gt;'use cache: private'&lt;/code&gt;&amp;nbsp;does not store its cache entries on the server. The function still runs during server rendering, but the cached result is kept only in the browser’s memory and does not survive a full page reload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters for Medusa and Other Headless Commerce Backends
&lt;/h2&gt;

&lt;p&gt;This pattern becomes especially valuable in headless commerce because the storefront is often responsible for combining multiple systems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;commerce backend&lt;/li&gt;
&lt;li&gt;CMS&lt;/li&gt;
&lt;li&gt;search service&lt;/li&gt;
&lt;li&gt;recommendation engine&lt;/li&gt;
&lt;li&gt;analytics and experiment layers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If all of those systems are fetched at request time for every page view, the frontend becomes slower and more fragile than it needs to be.&lt;/p&gt;

&lt;p&gt;If all of them are aggressively cached together, the storefront becomes fast but operationally unsafe.&lt;/p&gt;

&lt;p&gt;The right answer is to cache by business meaning, not by convenience.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CMS content can usually be cached until editors publish changes.&lt;/li&gt;
&lt;li&gt;catalog copy can usually be cached and invalidated via tags.&lt;/li&gt;
&lt;li&gt;regional shared pricing can often be cached briefly.&lt;/li&gt;
&lt;li&gt;logged-in B2B pricing should stay private.&lt;/li&gt;
&lt;li&gt;cart and checkout state should stay request-scoped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the difference between a caching strategy that helps the business and one that only looks good in benchmarks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using &lt;code&gt;revalidateTag&lt;/code&gt; to Keep Data Fresh
&lt;/h2&gt;

&lt;p&gt;One of the most useful patterns in Next.js 16 is tag-based invalidation.&lt;/p&gt;

&lt;p&gt;Instead of revalidating whole pages blindly, you can invalidate the exact data domain that changed. That is particularly useful when updates come from webhooks triggered by your CMS, PIM, or commerce backend.&lt;/p&gt;

&lt;p&gt;For example, if a product is updated in Medusa or an editorial team changes CMS content linked to that product, you can invalidate the relevant tag immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;revalidateTag&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`product:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&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;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nf"&gt;revalidateTag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`offer:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;regionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice, this lets you keep product content cached most of the time while still reacting quickly to actual changes.&lt;/p&gt;

&lt;p&gt;That is a much better fit for commerce than short global revalidation windows everywhere.&lt;/p&gt;

&lt;p&gt;One operational note matters here: never expose a public revalidation endpoint without request validation. If invalidation is triggered by a CMS, Medusa, or ERP webhook, verify the source with a shared secret or signature check before calling &lt;code&gt;revalidateTag&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Teams Usually Get It Wrong
&lt;/h2&gt;

&lt;p&gt;There are a few caching mistakes that repeatedly appear in storefront projects.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 1: Caching personalized pricing globally
&lt;/h3&gt;

&lt;p&gt;This is the most dangerous one. If the result depends on customer identity, customer group, contract terms, or session data, do not let it leak into a shared cache boundary.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 2: Making the whole page dynamic because one widget is dynamic
&lt;/h3&gt;

&lt;p&gt;A product page does not need to become fully request-time just because the cart badge or recommendation box is personalized.&lt;/p&gt;

&lt;p&gt;Keep the stable content cached and isolate the dynamic parts instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 3: Using &lt;code&gt;no-store&lt;/code&gt; too broadly
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;no-store&lt;/code&gt; is sometimes necessary, but if it becomes the default reaction to uncertainty, you throw away much of the value of the App Router architecture.&lt;/p&gt;

&lt;p&gt;Use it for truly request-bound data, not as a shortcut for unclear cache design.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 4: Forgetting business dimensions in cache keys
&lt;/h3&gt;

&lt;p&gt;Prices are rarely just “product price.” They are often tied to region, currency, channel, campaign, or customer group.&lt;/p&gt;

&lt;p&gt;If those dimensions are not reflected in the data boundary, the cache becomes unsafe.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mistake 5: Mixing editorial freshness with operational freshness
&lt;/h3&gt;

&lt;p&gt;A product description may be safe to cache until a content change occurs. Inventory and promotions may not be. These two layers should not be forced into the same lifetime.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Good Default Model for Product Pages
&lt;/h2&gt;

&lt;p&gt;If you need a practical starting point, this model works well for many storefronts:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Data type&lt;/th&gt;
&lt;th&gt;Suggested approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Product description, SEO copy, editorial blocks&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;use cache&lt;/code&gt; with tags and on-demand invalidation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared regional price and stock&lt;/td&gt;
&lt;td&gt;short cache lifetime plus tags&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recommendations&lt;/td&gt;
&lt;td&gt;private or isolated dynamic boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cart state&lt;/td&gt;
&lt;td&gt;request-time or session-specific logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkout totals and payment readiness&lt;/td&gt;
&lt;td&gt;request-time only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B2B contract pricing&lt;/td&gt;
&lt;td&gt;private boundary, never generic shared output&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is not a framework rule. It is a business-safe baseline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Works Well With Streaming and PPR Thinking
&lt;/h2&gt;

&lt;p&gt;If you have already explored Partial Prerendering in Next.js 16, this caching model should feel familiar.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;keep the stable shell fast&lt;/li&gt;
&lt;li&gt;isolate dynamic or high-volatility parts&lt;/li&gt;
&lt;li&gt;avoid blocking the entire page for data that does not deserve that privilege&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For commerce, that means a product page can still load quickly for users and search engines while the truly dynamic fragments remain fresh.&lt;/p&gt;

&lt;p&gt;That is a much more scalable model than forcing every route into either “fully static” or “fully dynamic.”&lt;/p&gt;

&lt;h2&gt;
  
  
  A Note on Webhooks and Operational Ownership
&lt;/h2&gt;

&lt;p&gt;Caching only works well if invalidation is treated as part of the architecture, not as an afterthought.&lt;/p&gt;

&lt;p&gt;If your team updates product data in Medusa, content in a CMS, or prices in an ERP, your storefront should know how those changes trigger cache invalidation.&lt;/p&gt;

&lt;p&gt;Typical patterns include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Medusa webhooks invalidating product and offer tags&lt;/li&gt;
&lt;li&gt;CMS publish events invalidating page or content tags&lt;/li&gt;
&lt;li&gt;ERP sync jobs invalidating pricing-related tags&lt;/li&gt;
&lt;li&gt;stock update events invalidating inventory-related tags only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where many storefronts move from “fast in development” to “reliable in production.”&lt;/p&gt;

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

&lt;p&gt;The real value of caching in Next.js 16 is not that it makes everything static. It is that it helps you decide what should be shared, what should be refreshed, and what should remain private.&lt;/p&gt;

&lt;p&gt;For e-commerce teams, that distinction matters more than raw speed numbers. The fastest storefront is not the one that caches the most. It is the one that caches the right things.&lt;/p&gt;

&lt;p&gt;If you separate stable catalog content, shared operational data, and user-specific state into different cache boundaries, you can keep your storefront fast without risking stale or incorrect business data.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Asset-Based Data Orchestration: Lessons from Building a Multi-State Social Data Platform</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Thu, 26 Mar 2026 07:21:25 +0000</pubDate>
      <link>https://dev.to/u11d/asset-based-data-orchestration-lessons-from-building-a-multi-state-social-data-platform-9l</link>
      <guid>https://dev.to/u11d/asset-based-data-orchestration-lessons-from-building-a-multi-state-social-data-platform-9l</guid>
      <description>&lt;p&gt;Building reliable data platforms rarely fails because of scale alone.&lt;br&gt;
More often, reliability collapses under &lt;strong&gt;heterogeneity&lt;/strong&gt;: multiple data providers, inconsistent schemas, partial updates, and unclear ownership.&lt;/p&gt;

&lt;p&gt;While building a multi-state social data platform ingesting resource data from dozens of organizations, we discovered that reliability is not a property of pipelines. It is a property of &lt;strong&gt;data artifacts and their relationships&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Why Reliability Becomes a Systems Problem at Scale
&lt;/h2&gt;

&lt;p&gt;The accompanying essay frames trust as something earned through consistent system behavior under real-world pressure. What that framing leaves implicit, but what became unavoidable in practice, is that reliability stops being a property of individual components very early on. Once multiple organizations, jurisdictions, and publishing surfaces are involved, reliability becomes an emergent property of the &lt;em&gt;entire system&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;For us, this meant that no single pipeline, connector, or database could be made "reliable enough" in isolation. Failures were rarely total. They were partial, localized, and often silent. The engineering challenge was not preventing all failure, but designing the system so that failures were isolated, detectable, and explainable.&lt;/p&gt;

&lt;p&gt;That requirement drove nearly every architectural decision that followed.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. System Overview
&lt;/h2&gt;

&lt;p&gt;Technically, the platform ingests social service resource data from independent organizations operating across multiple U.S. states. Each organization exposes data via a different source system (for example iCarol, WellSky, VisionLink, RTM), with varying schemas, update cadences, and quality guarantees.&lt;/p&gt;

&lt;p&gt;At a high level, the system consists of:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Writers&lt;/strong&gt; - per-tenant ingestion and transformation projects that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fetch raw data from source systems via connector adapters&lt;/li&gt;
&lt;li&gt;Persist raw data into Snowflake source schemas&lt;/li&gt;
&lt;li&gt;Normalize and standardize data via DBT&lt;/li&gt;
&lt;li&gt;Apply enhancements such as geocoding or translations&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Readers&lt;/strong&gt; - publisher processes that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React to completed writer runs, either bulk or incremental&lt;/li&gt;
&lt;li&gt;Publish curated artifacts to OpenSearch, MongoDB, and optionally Postgres&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Snowflake acts as the system of record for intermediate and normalized datasets. Dagster coordinates the execution and materialization of data assets. DBT is used explicitly for set-based transformations, not orchestration.&lt;/p&gt;

&lt;p&gt;Scale is not extreme in raw volume, but complexity is high: dozens of tenants, hundreds of tables per tenant, and frequent partial updates.&lt;/p&gt;
&lt;h3&gt;
  
  
  High-Level Architecture
&lt;/h3&gt;

&lt;p&gt;The following diagram illustrates the writer → Snowflake → DBT → asset orchestration → readers pattern.&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%2Fgmvl4kvl30b7i0uowsfj.webp" 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%2Fgmvl4kvl30b7i0uowsfj.webp" alt="Asset based data orchestration high level architecture" width="800" height="216"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The platform is designed around &lt;strong&gt;asset lineage and normalization contracts&lt;/strong&gt;, not pipelines.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Source Heterogeneity as the Dominant Constraint
&lt;/h2&gt;

&lt;p&gt;The hardest constraint was not throughput or storage. It was heterogeneity.&lt;/p&gt;

&lt;p&gt;Each data provider differed along several axes simultaneously:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Schema shape&lt;/strong&gt;: even when nominally "the same" entities existed, fields varied&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantics&lt;/strong&gt;: identical fields often meant subtly different things&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update cadence&lt;/strong&gt;: some sources updated continuously, others weekly or ad hoc&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality guarantees&lt;/strong&gt;: missing fields, stale records, or partial exports were common&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Early on, we underestimated how strongly these differences would dominate system design. Attempting to treat ingestion as a uniform, pipeline-shaped process led to brittle assumptions and cross-tenant coupling.&lt;/p&gt;

&lt;p&gt;The system only became manageable once heterogeneity was treated as &lt;em&gt;fundamental&lt;/em&gt;, not incidental.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Normalization (HSDS, SDOH or Equivalent) as an Architectural Contract
&lt;/h2&gt;

&lt;p&gt;Normalization into an HSDS-like model was not implemented as a downstream convenience. It became an architectural contract.&lt;/p&gt;

&lt;p&gt;All downstream consumers, internal and external, implicitly rely on the guarantees of the normalized model: stable fields, predictable relationships, and documented semantics. That meant normalization could not be "best effort" or delayed until the end of a pipeline.&lt;/p&gt;

&lt;p&gt;In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Raw source data is written verbatim into Snowflake source schemas&lt;/li&gt;
&lt;li&gt;DBT ELT projects transform this raw data into standardized intermediate models&lt;/li&gt;
&lt;li&gt;DBT STAGE projects apply tenant-specific adaptations while preserving the contract&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This separation made it explicit where interpretation happens. If a field is wrong in the normalized model, the question becomes &lt;em&gt;which contract was violated&lt;/em&gt;, not "what broke in the pipeline".&lt;/p&gt;
&lt;h2&gt;
  
  
  5. What We Got Wrong Initially
&lt;/h2&gt;

&lt;p&gt;Several early assumptions did not survive contact with reality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pipeline-first thinking&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
We initially modeled work as long-running jobs. This obscured which intermediate datasets were durable, reusable, or safe to depend on. Debugging often meant rerunning more than necessary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual validation&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Data quality checks lived outside the orchestration layer. Engineers and analysts manually inspected outputs, which worked at small scale but failed under concurrency and time pressure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared failure domains&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Multiple tenants often shared execution paths. A failure in one tenant’s ingestion could block or delay others, even when their data was unrelated.&lt;/p&gt;

&lt;p&gt;None of these issues were catastrophic individually. Together, they made reliability depend on human attention.&lt;/p&gt;
&lt;h2&gt;
  
  
  6. Transitioning to Asset-Based Data Orchestration with Dagster
&lt;/h2&gt;

&lt;p&gt;The shift to asset-based orchestration was driven less by tooling preference and more by a change in mental model.&lt;/p&gt;

&lt;p&gt;Instead of asking "what jobs should run?", we started asking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What data artifacts must exist?&lt;/li&gt;
&lt;li&gt;What do they depend on?&lt;/li&gt;
&lt;li&gt;How fresh do they need to be?&lt;/li&gt;
&lt;li&gt;What constitutes success or failure for &lt;em&gt;this&lt;/em&gt; artifact?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dagster assets provided a way to encode those questions directly.&lt;/p&gt;

&lt;p&gt;A simplified example from a writer project shows how DBT models are treated as assets rather than opaque steps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# writer-xyz/assets.py (excerpt)
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dagster_dbt&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_assets_from_dbt_project&lt;/span&gt;

&lt;span class="n"&gt;dbt_elt_assets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_assets_from_dbt_project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;project_dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dbt_elt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;profiles_dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dbt_elt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does not explain how DBT runs. It declares that the resulting tables are first-class assets with lineage and state.&lt;/p&gt;

&lt;p&gt;Once assets replaced jobs as the primary abstraction, freshness, lineage, and partial recomputation became explicit rather than implicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Partitioning for Failure Isolation
&lt;/h2&gt;

&lt;p&gt;Partitioning was critical for isolating failures.&lt;/p&gt;

&lt;p&gt;We partitioned primarily along tenant and state boundaries, not time. This reflected operational reality: data issues almost always affected a single organization or region.&lt;/p&gt;

&lt;p&gt;In Dagster terms, this meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Separate writer projects per tenant&lt;/li&gt;
&lt;li&gt;Independent schedules and sensors&lt;/li&gt;
&lt;li&gt;Asset materializations scoped to a tenant’s data domain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A failure in one writer no longer blocked publishing for others. More importantly, remediation could be targeted and auditable.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Data Quality Embedded in the Asset Graph
&lt;/h2&gt;

&lt;p&gt;Data validation moved into the asset graph itself.&lt;/p&gt;

&lt;p&gt;Instead of post-hoc checks, validations became explicit dependencies. If a validation asset failed, downstream assets simply did not materialize.&lt;/p&gt;

&lt;p&gt;An example pattern used across writers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@asset&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_staging_tables&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;staging_tables&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;staging_tables&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count_missing_ids&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is intentionally simple. The key point is not the check itself, but that failure is structural. The system records that an expected artifact does not exist, rather than silently publishing bad data.&lt;/p&gt;

&lt;p&gt;This shifted failure detection earlier and reduced the blast radius of errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Operational Outcomes
&lt;/h2&gt;

&lt;p&gt;Day-to-day operations changed in several concrete ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On-call work shifted from rerunning pipelines to inspecting asset lineage&lt;/li&gt;
&lt;li&gt;Partial backfills became routine rather than exceptional&lt;/li&gt;
&lt;li&gt;Publishing delays were easier to attribute to specific upstream causes&lt;/li&gt;
&lt;li&gt;New tenants could be added without increasing shared operational risk&lt;/li&gt;
&lt;li&gt;None of this eliminated operational effort. It made that effort more focused and less reactive.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  10. Open Trade-offs and Unresolved Questions
&lt;/h2&gt;

&lt;p&gt;Some challenges remain unresolved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cross-tenant schema evolution still requires coordination and discipline&lt;/li&gt;
&lt;li&gt;Observability across Snowflake, DBT, Dagster, and downstream stores is fragmented&lt;/li&gt;
&lt;li&gt;Cost attribution at the asset level is still coarse-grained&lt;/li&gt;
&lt;li&gt;Human review remains necessary for certain semantic validations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With more time, we would invest earlier in unified observability and more formal schema versioning.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Why These Lessons Matter Beyond This Platform
&lt;/h2&gt;

&lt;p&gt;These lessons are not unique to this system.&lt;/p&gt;

&lt;p&gt;Any platform operating in civic tech, govtech, or environmental data shares similar constraints: multiple data producers, uneven quality, and real-world consequences for failure.&lt;/p&gt;

&lt;p&gt;The core takeaway is not "use asset-based orchestration", but treat data artifacts as obligations. Once that shift happens, many architectural decisions become clearer.&lt;/p&gt;

&lt;p&gt;Reliability stops being something you hope for and becomes something you can reason about.&lt;/p&gt;

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

&lt;p&gt;The biggest lesson from this platform was not about any particular tool. It was about how we model the system itself.&lt;/p&gt;

&lt;p&gt;Once data artifacts became the core abstraction, many reliability problems became easier to reason about. Failures became visible, dependencies became explicit, and operational work shifted from firefighting pipelines to managing data contracts.&lt;/p&gt;

&lt;p&gt;For data platforms operating across heterogeneous sources, this shift can be the difference between a system that merely runs - and one that can be trusted.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What is asset-based data orchestration?
&lt;/h3&gt;

&lt;p&gt;Asset-based data orchestration treats data artifacts (tables, datasets, models) as the primary units of orchestration instead of pipelines or jobs. Systems like Dagster allow teams to define dependencies between assets, enabling better lineage tracking, partial recomputation, and failure isolation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is asset-based orchestration useful for complex data platforms?
&lt;/h3&gt;

&lt;p&gt;In large systems with many data producers and consumers, failures are rarely binary. Asset-based orchestration makes dependencies explicit, allowing teams to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;isolate failures&lt;/li&gt;
&lt;li&gt;recompute only affected datasets&lt;/li&gt;
&lt;li&gt;track lineage across transformations&lt;/li&gt;
&lt;li&gt;enforce data quality checks before publishing&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  How does Dagster differ from traditional pipeline orchestrators?
&lt;/h3&gt;

&lt;p&gt;Traditional orchestrators schedule jobs or workflows. Dagster emphasizes data assets and lineage. This enables better visibility into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what datasets exist&lt;/li&gt;
&lt;li&gt;what produced them&lt;/li&gt;
&lt;li&gt;what depends on them&lt;/li&gt;
&lt;li&gt;whether they meet freshness or quality requirements&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  When should teams adopt asset-based orchestration?
&lt;/h3&gt;

&lt;p&gt;Asset-based orchestration becomes particularly valuable when systems have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;many data sources&lt;/li&gt;
&lt;li&gt;heterogeneous schemas&lt;/li&gt;
&lt;li&gt;multiple downstream consumers&lt;/li&gt;
&lt;li&gt;partial or incremental updates&lt;/li&gt;
&lt;li&gt;strict reliability requirements&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These conditions are common in &lt;strong&gt;multi-tenant data platforms and civic tech systems&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does asset-based orchestration replace tools like DBT?
&lt;/h3&gt;

&lt;p&gt;No. Asset-based orchestration &lt;strong&gt;complements transformation tools&lt;/strong&gt; like DBT. In many architectures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DBT performs &lt;strong&gt;set-based transformations&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Dagster manages &lt;strong&gt;asset lineage, dependencies, scheduling, and validation&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This separation keeps orchestration and transformation responsibilities clear.&lt;/p&gt;

</description>
      <category>dagster</category>
      <category>dataorchestration</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>Next.js 16 Partial Prerendering (PPR): The Best of Static and Dynamic Rendering</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Thu, 05 Mar 2026 08:21:46 +0000</pubDate>
      <link>https://dev.to/u11d/nextjs-16-partial-prerendering-ppr-the-best-of-static-and-dynamic-rendering-2fgg</link>
      <guid>https://dev.to/u11d/nextjs-16-partial-prerendering-ppr-the-best-of-static-and-dynamic-rendering-2fgg</guid>
      <description>&lt;p&gt;Next.js has long been a leader in giving developers flexible, high-performance rendering strategies - &lt;a href="https://u11d.com/blog/ssg-isr-ssr-csr-which-strategy-should-i-use-in-my-next-js-e-commerce-platform/" rel="noopener noreferrer"&gt;Static Site Generation (SSG), Server-Side Rendering (SSR), Incremental Static Regeneration (ISR), and Client-Side Rendering (CSR)&lt;/a&gt; all play roles depending on your use case. With Next.js 14, Partial Prerendering (PPR) was introduced as an experimental feature, starting from Next.js 16, PPR has become a stable and becomes a foundational part of this landscape by letting you&amp;nbsp;&lt;em&gt;blend static pre-rendering and dynamic behavior in the same route&lt;/em&gt;, improving perceived performance and SEO without sacrificing real-time data needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What Is Partial Prerendering (PPR)?&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Partial Prerendering&lt;/strong&gt;&amp;nbsp;is a rendering strategy that lets Next.js deliver a&amp;nbsp;&lt;strong&gt;static HTML “shell” immediately&lt;/strong&gt;, then&amp;nbsp;&lt;em&gt;stream in dynamic content&lt;/em&gt;&amp;nbsp;as it becomes available - &lt;em&gt;all in the same server response&lt;/em&gt;. Instead of choosing static&amp;nbsp;&lt;em&gt;or&lt;/em&gt;&amp;nbsp;dynamic at the page level, PPR combines both on a per-component basis.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The&amp;nbsp;&lt;strong&gt;static shell&lt;/strong&gt;&amp;nbsp;(layout, logo, product titles, navigation) is pre-generated at build time or cached ahead of requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic parts&lt;/strong&gt;&amp;nbsp;(cart state, recommendations, session data) load later and are&amp;nbsp;&lt;em&gt;streamed&lt;/em&gt;&amp;nbsp;to the client using React’s&amp;nbsp;&lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt;&amp;nbsp;boundaries.&lt;/li&gt;
&lt;li&gt;Users see meaningful UI immediately, with dynamic content filling in seamlessly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This leads to&amp;nbsp;&lt;em&gt;faster Time to First Byte (TTFB)&lt;/em&gt;&amp;nbsp;and a smoother perceived experience, while still allowing data personalization and real-time updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;How Partial Prerendering Works in Next.js 16&lt;/strong&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Cache Components: The Heart of PPR&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;In Next.js 16, PPR isn’t just an experimental flag - it’s integrated into the&amp;nbsp;&lt;a href="https://nextjs.org/docs/app/getting-started/cache-components" rel="noopener noreferrer"&gt;&lt;strong&gt;Cache Components&lt;/strong&gt;&lt;/a&gt;&amp;nbsp;system. Cache Components lets you&amp;nbsp;&lt;em&gt;opt-in to cache certain components or data instead of rendering them on every request&lt;/em&gt;, making pre-rendering more predictable and performant.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cacheComponents: true&lt;/code&gt;&lt;/strong&gt;&amp;nbsp;in&amp;nbsp;&lt;code&gt;next.config.ts&lt;/code&gt;&amp;nbsp;enables PPR and component-level caching.&lt;/li&gt;
&lt;li&gt;The new&amp;nbsp;&lt;a href="https://nextjs.org/docs/app/getting-started/cache-components#during-prerendering" rel="noopener noreferrer"&gt;&lt;code&gt;"use cache"&lt;/code&gt;&lt;/a&gt;&amp;nbsp;directive lets you mark data-fetching functions or components as cacheable.&lt;/li&gt;
&lt;li&gt;Anything not cacheable or depending on request-specific data can be wrapped in a&amp;nbsp;&lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt;&amp;nbsp;fallback.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This approach gives you fine-grained control: static UI is reused across requests, while dynamic parts rehydrate or stream in only when needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Streaming and Suspense: The Engine Under the Hood&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;React’s&amp;nbsp;&lt;strong&gt;&lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt;&lt;/strong&gt;&amp;nbsp;plays a key role in PPR:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wrap dynamic components in&amp;nbsp;&lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt;&amp;nbsp;with a fallback UI (like a skeleton loader).&lt;/li&gt;
&lt;li&gt;During rendering, Suspense tells Next.js where to&amp;nbsp;&lt;em&gt;halt pre-rendering&lt;/em&gt;&amp;nbsp;and stream dynamic content later.&lt;/li&gt;
&lt;li&gt;The server sends the pre-rendered shell immediately and then streams dynamic sections in parallel as they’re ready.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This strategy avoids blocks in page delivery and reduces the “white screen” effect often seen in traditional SSR or CSR approaches.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why PPR Matters for E-commerce&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;For e-commerce platforms, performance directly impacts sales and SEO - factors also emphasized in the U11D articles about rendering strategies. A slow page can hurt conversions and search rankings. Partial Prerendering improves this by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Faster initial loads:&lt;/strong&gt;&amp;nbsp;Users see the page skeleton instantly, improving engagement and performance metrics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SEO advantages:&lt;/strong&gt;&amp;nbsp;Static content is available for crawler indexing immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic personalization:&lt;/strong&gt;&amp;nbsp;Cart contents, recommendations, user-specific prices or availability can appear without blocking the initial render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More flexible than SSG/SSR alone:&lt;/strong&gt;&amp;nbsp;You’re not limited to fully static or completely dynamic pages - the best of both lives together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In e-commerce apps where product detail pages might be static for most users but show personalized recommendations or inventory status, PPR is a compelling hybrid.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;How to Enable and Use PPR in Next.js 16&lt;/strong&gt;
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Enable Cache Components:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In your&amp;nbsp;&lt;code&gt;next.config.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;cacheComponents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// enables Partial Prerendering&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

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

&lt;/code&gt;&lt;/pre&gt;

&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Wrap dynamic UI:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use React’s&amp;nbsp;&lt;code&gt;Suspense&lt;/code&gt;&amp;nbsp;for dynamic content:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Suspense&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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="nc"&gt;Header&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Loading product...&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProductDetails&lt;/span&gt; &lt;span class="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;/&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;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Use&amp;nbsp;&lt;code&gt;use cache&lt;/code&gt;&amp;nbsp;for predictable dynamic data:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Inside dynamic data functions, prefix with&amp;nbsp;&lt;code&gt;"use cache"&lt;/code&gt;&amp;nbsp;to mark them cacheable:&lt;br&gt;
&lt;/p&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getProduct&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;/code&gt;&lt;/pre&gt;


&lt;p&gt;This tells Next.js that caching is safe for that data.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;How PPR Fits with Other Rendering Strategies&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Partial Prerendering doesn’t replace SSR, SSG, ISR, or CSR - but&amp;nbsp;&lt;em&gt;coordinates with them&lt;/em&gt;. While U11D’s articles give a great overview of traditional strategies like SSG, SSR, and ISR, PPR adds a hybrid strategy that sits between:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SSG/ISR:&lt;/strong&gt;&amp;nbsp;Good for full static pages or pages that regenerate occasionally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSR:&lt;/strong&gt;&amp;nbsp;Ideal for real-time personalized content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSR:&lt;/strong&gt;&amp;nbsp;Great for highly interactive client-heavy UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PPR (Next.js 16):&lt;/strong&gt;&amp;nbsp;Combines static cached shells with streaming dynamic content.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Think of PPR as a&amp;nbsp;&lt;em&gt;component-level SSG + dynamic hybrid -&lt;/em&gt; fast to load like SSG, flexible like SSR.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Wrapping Up&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Next.js 16 elevates Partial Prerendering from an experimental concept to a practical, integrated performance strategy through&amp;nbsp;&lt;strong&gt;Cache Components&lt;/strong&gt;&amp;nbsp;and React Suspense. It’s especially powerful for complex, dynamic sites like e-commerce stores, where you want the benefits of static pre-rendering&amp;nbsp;&lt;em&gt;and&lt;/em&gt;&amp;nbsp;dynamic personalization without sacrificing UX or SEO.&lt;/p&gt;

&lt;p&gt;By serving instantly usable HTML and streaming dynamic parts in parallel, PPR bridges the traditional divide between static and dynamic rendering - helping your app&amp;nbsp;&lt;em&gt;feel&lt;/em&gt;&amp;nbsp;faster while staying robust and scalable.&lt;/p&gt;

&lt;p&gt;If you’re building with Next.js 16, definitely explore PPR alongside other strategies like SSR, ISR, and CSR to find the most performance-optimized combination for your routes.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>ppr</category>
    </item>
    <item>
      <title>Using Proxy (before Middleware) in Next.js: a modern layer</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Wed, 26 Nov 2025 07:00:00 +0000</pubDate>
      <link>https://dev.to/u11d/using-proxy-before-middleware-in-nextjs-a-modern-layer-1iik</link>
      <guid>https://dev.to/u11d/using-proxy-before-middleware-in-nextjs-a-modern-layer-1iik</guid>
      <description>&lt;p&gt;&lt;a href="https://nextjs.org/blog/next-16" rel="noopener noreferrer"&gt;Next.js 16&lt;/a&gt; introduces a new file-convention:&amp;nbsp;&lt;code&gt;proxy.js&lt;/code&gt;&amp;nbsp;/&amp;nbsp;&lt;code&gt;proxy.ts&lt;/code&gt;&amp;nbsp;(often “Proxy”) which supersedes the older&amp;nbsp;&lt;code&gt;middleware.js&lt;/code&gt;. This feature allows you to intercept HTTP requests early (before routing/rendering) and run custom logic—redirects, rewrites, header manipulation, or forwarding (proxying) to another service.&lt;/p&gt;

&lt;p&gt;In e-commerce (or any frontend/back-end scenario) this gives you a powerful tool: you can make your Next.js app act as a&amp;nbsp;&lt;strong&gt;Backend-for-Frontend (BFF)&lt;/strong&gt;&amp;nbsp;layer, hiding upstream APIs, consolidating micro-services, enforcing auth, caching, rewriting URLs, etc. This aligns well with many of the architectures discussed in your referenced articles about Next.js for commerce (SSG/ISR/SSR strategies etc).&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Proxy and when to use it
&lt;/h2&gt;

&lt;p&gt;The Proxy file (&lt;code&gt;proxy.js&lt;/code&gt;/&lt;code&gt;.ts&lt;/code&gt;) in Next.js is a special server-side file (located in the project root or under&amp;nbsp;&lt;code&gt;src&lt;/code&gt;) that runs&amp;nbsp;&lt;em&gt;before&lt;/em&gt;&amp;nbsp;any route handling or page rendering occurs. In fact, Proxy (and Middleware) executes before&amp;nbsp;&lt;strong&gt;every&lt;/strong&gt;&amp;nbsp;incoming request — not just those for pages or APIs — including requests for assets like images, icons, or JavaScript files. Because of this, Next.js provides the&amp;nbsp;&lt;code&gt;matcher&lt;/code&gt;&amp;nbsp;configuration, allowing you to specify which routes the Proxy should apply to and avoid intercepting unnecessary requests.&lt;/p&gt;

&lt;p&gt;It allows you to inspect the incoming&amp;nbsp;&lt;code&gt;request&lt;/code&gt;, then respond, redirect, rewrite, or modify headers based on your application’s needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Relation to Middleware &amp;amp; Rewrites&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;In previous versions,&amp;nbsp;&lt;code&gt;middleware.js&lt;/code&gt;&amp;nbsp;(App Router) was used for this pattern; Proxy replaces it.&lt;/li&gt;
&lt;li&gt;While&amp;nbsp;&lt;code&gt;rewrites()&lt;/code&gt;&amp;nbsp;in&amp;nbsp;&lt;code&gt;next.config.js&lt;/code&gt;&amp;nbsp;can act like a proxy by mapping URL paths to &lt;a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/rewrites" rel="noopener noreferrer"&gt;different destinations&lt;/a&gt;, the Proxy feature goes further — it gives you direct access to the&amp;nbsp;&lt;strong&gt;request object itself&lt;/strong&gt;, including headers, cookies, and other request data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;When to use Proxy&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Proxy works best in scenarios that require lightweight, real-time adjustments to incoming requests — such as performing quick redirects based on request details, dynamically rewriting routes for A/B testing or experimental features, or modifying request and response headers across specific routes or the entire application.&lt;/p&gt;

&lt;p&gt;However, it’s not well-suited for tasks involving heavy data fetching or complex session management, as those are better handled by dedicated API routes or backend services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Relation to Backend-for-Frontend (BFF) pattern&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the BFF pattern, the frontend has a dedicated backend layer (the “for the frontend”) that aggregates, transforms, or proxies requests to multiple microservices. Next.js supports this pattern via Route Handlers,&amp;nbsp;Proxy and server actions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up Proxy: conventions &amp;amp; example
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;File convention&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create a&amp;nbsp;&lt;code&gt;proxy.ts&lt;/code&gt;&amp;nbsp;(or&amp;nbsp;&lt;code&gt;proxy.js&lt;/code&gt;) file in the&amp;nbsp;&lt;strong&gt;project root&lt;/strong&gt;&amp;nbsp;or inside&amp;nbsp;&lt;code&gt;src/&lt;/code&gt;&amp;nbsp;alongside your top‐level&amp;nbsp;&lt;code&gt;app/&lt;/code&gt;&amp;nbsp;or&amp;nbsp;&lt;code&gt;pages/&lt;/code&gt;folder.&lt;/li&gt;
&lt;li&gt;Only one proxy file per project is supported—though you can modularize logic within it by importing helpers.&lt;/li&gt;
&lt;li&gt;Export a function named&amp;nbsp;&lt;code&gt;proxy&lt;/code&gt;&amp;nbsp;(or default) that receives a&amp;nbsp;&lt;code&gt;NextRequest&lt;/code&gt;&amp;nbsp;and returns a&amp;nbsp;&lt;code&gt;NextResponse&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Optionally export a&amp;nbsp;&lt;code&gt;config&lt;/code&gt;&amp;nbsp;object with a&amp;nbsp;&lt;code&gt;matcher&lt;/code&gt;&amp;nbsp;property to specify which routes this proxy applies to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Basic example&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// proxy.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;proxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// simple redirect&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/home&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/about/:path*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This intercepts&amp;nbsp;&lt;code&gt;/about/*&lt;/code&gt;&amp;nbsp;and sends users to&amp;nbsp;&lt;code&gt;/home&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Proxying to different pathname&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// proxy.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;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;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/v1/docs&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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/v2/docs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way&amp;nbsp;&lt;code&gt;/v1/docs&lt;/code&gt;&amp;nbsp;in your Next.js app becomes a proxy to your &lt;code&gt;v2&lt;/code&gt; version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important flags &amp;amp; matching&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;matcher&lt;/code&gt;&amp;nbsp;can be a string, array of strings, or objects with&amp;nbsp;&lt;code&gt;source&lt;/code&gt;,&amp;nbsp;&lt;code&gt;has&lt;/code&gt;,&amp;nbsp;&lt;code&gt;missing&lt;/code&gt;,&amp;nbsp;&lt;code&gt;regexp&lt;/code&gt;&amp;nbsp;for fine control.&lt;/li&gt;
&lt;li&gt;You should exclude internal Next.js paths such as&amp;nbsp;&lt;code&gt;/_next/&lt;/code&gt;, static assets etc, to avoid intercepting everything accidentally.&lt;/li&gt;
&lt;li&gt;Be aware of runtime: The Proxy is executed by default in the Edge Runtime environment; performance matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Use cases &amp;amp; how it fits e-commerce / frontend/back-end scenarios
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use case 1: Authentication / session gating before forwarding&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before forwarding to upstream API, you can inspect cookies/headers in Proxy, validate session, add or strip auth headers, or reject unauthorized requests early. This centralises “frontend backend” logic.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sessionToken&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Use case 2: A/B testing, feature flags, routing to different backends&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;E-commerce often runs experiments: you might want to route certain requests to a “new checkout backend” or “legacy order API” based on a feature flag. Proxy allows dynamic routing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;featureFlag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;newCheckout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/checkout&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;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rewrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/new-checkout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is exactly where Proxy shines, beyond simple rewrites. The docs mention this scenario.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use case 3: Multi-zone / micro-frontend routing&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you break your site into zones (e.g.,&amp;nbsp;&lt;code&gt;/shop&lt;/code&gt;,&amp;nbsp;&lt;code&gt;/blog&lt;/code&gt;,&amp;nbsp;&lt;code&gt;/dashboard&lt;/code&gt;) and each is a separate Next.js app or separate backend service, you can use Proxy to route to the correct service based on path or condition. The docs mention this under Multi-Zones: “Proxy can also be used when there is a need for a dynamic decision when routing.”&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How this complements SSG/ISR/SSR strategies&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In your e-commerce context (as your referenced articles explore SSG/ISR/SSR), Proxy supports architecture by giving you control over how client-requests hit your backend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you statically generate product pages (SSG/ISR) but still need dynamic APIs behind the scenes (inventory, pricing), Proxy sits in front of those APIs.&lt;/li&gt;
&lt;li&gt;If you use SSR for checkout or account pages, you might fetch via your BFF rather than directly from client; Proxy gives you that layer.&lt;/li&gt;
&lt;li&gt;This allows you to&amp;nbsp;&lt;strong&gt;decouple&lt;/strong&gt;&amp;nbsp;the frontend build (Next.js) from the backend services, while still providing unified domain, consistent cookies/sessions, and reduction of client-side complexity.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The Proxy file convention in Next.js is a modern, flexible tool for intercepting HTTP requests early in your application lifecycle. It’s especially valuable in setups where your frontend is part of a larger ecosystem (microservices, e-commerce, feature-flagged experiments) and you need a lightweight backend-for-frontend layer without building a full custom server.&lt;/p&gt;

&lt;p&gt;For e-commerce platforms built with Next.js—where you may have static product pages, dynamic inventory/pricing APIs, customer sessions, checkout flows—the Proxy gives you an elegant place to manage domain-consolidation, routing, auth, A/B experimentation and API facade logic.&lt;/p&gt;

&lt;p&gt;However, you should use it wisely: keep logic lean, use it for the right cases (redirects, rewrites, header manipulation, request forwarding) rather than heavy data aggregation. Combined with Next.js’s rendering strategies (SSG, ISR, SSR) and BFF pattern, Proxy becomes an important architectural building block.&lt;/p&gt;

</description>
      <category>nextjs</category>
    </item>
    <item>
      <title>Automated Tax Compliance for Global E-Commerce Growth</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Mon, 17 Nov 2025 08:06:29 +0000</pubDate>
      <link>https://dev.to/u11d/automated-tax-compliance-for-global-e-commerce-growth-5g8b</link>
      <guid>https://dev.to/u11d/automated-tax-compliance-for-global-e-commerce-growth-5g8b</guid>
      <description>&lt;p&gt;Choosing an e-commerce platform today means choosing your capabilities for the next 5-10 years. Most businesses focus on features and design, overlooking a critical question: can this platform handle tax compliance as we grow, regulations change, and new sales channels emerge?&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Compliance Challenges That Break Legacy Platforms
&lt;/h2&gt;

&lt;p&gt;Real-time performance matters more than you think. Every millisecond at checkout affects conversion. Legacy tax calculation systems add 300-500ms latency per request — enough to impact sales. Worse, they often fail silently during high-traffic periods, forcing you to choose between lost sales or compliance risk.&lt;/p&gt;

&lt;p&gt;Headless architecture becomes critical as your business evolves. Selling through mobile apps, marketplaces, social commerce, and IoT devices means your tax system needs API-first design. Shopify's monolithic architecture forces you into their frontend. WooCommerce wasn't built for headless operations. Magento's complexity makes omnichannel integration expensive.&lt;/p&gt;

&lt;p&gt;Audit readiness isn't optional. Tax authorities increasingly audit e-commerce businesses, and "we did our best" isn't a defense. You need complete transaction history, documentation of every tax decision, and proof that calculations were accurate at transaction time. Most platforms treat this as an afterthought, storing minimal data that won't satisfy auditors.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Performance-Compliance Tradeoff (That Shouldn't Exist)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.avalara.com/us/en/index.html" rel="noopener noreferrer"&gt;Avalara's&lt;/a&gt; real-time API calculates taxes in under 100ms, handling peak traffic without degradation. This isn't just about speed — it's about reliability. During Black Friday traffic spikes, tax calculations don't become a bottleneck.&lt;/p&gt;

&lt;p&gt;Combined with Medusa's modern architecture, you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sub-second checkout experiences with real-time tax accuracy&lt;/li&gt;
&lt;li&gt;Async tax calculation options for ultra-high-volume scenarios&lt;/li&gt;
&lt;li&gt;Automatic failover and retry logic for maximum reliability&lt;/li&gt;
&lt;li&gt;Complete transaction logging for audit trails&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Technical Foundation Matters
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://medusajs.com/" rel="noopener noreferrer"&gt;Medusa's&lt;/a&gt; API-first, headless design means tax compliance works consistently across every channel. Selling through your website, mobile app, and Amazon? Same tax logic, same accuracy, same audit trail — without custom integration for each channel.&lt;/p&gt;

&lt;p&gt;This architecture also enables sophisticated business rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Customer-specific tax exemptions applied automatically across all channels&lt;/li&gt;
&lt;li&gt;Dynamic tax calculation based on real-time inventory location&lt;/li&gt;
&lt;li&gt;Split fulfillment from multiple warehouses with correct origin-based taxation&lt;/li&gt;
&lt;li&gt;International expansion without rebuilding your tax infrastructure&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Building Audit-Ready From Day One
&lt;/h2&gt;

&lt;p&gt;When tax authorities audit your business, they want answers to specific questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How did you determine tax rates for each transaction?&lt;/li&gt;
&lt;li&gt;Where are exemption certificates for tax-exempt sales?&lt;/li&gt;
&lt;li&gt;How do you handle returns and refunds?&lt;/li&gt;
&lt;li&gt;What's your documentation for nexus determination?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://www.npmjs.com/package/@u11d/medusa-avalara" rel="noopener noreferrer"&gt;Medusa-Avalara integration&lt;/a&gt; maintains complete records automatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every tax calculation with jurisdiction breakdown&lt;/li&gt;
&lt;li&gt;Customer exemption certificates with validation status&lt;/li&gt;
&lt;li&gt;Transaction lifecycle (calculated → committed → voided/refunded)&lt;/li&gt;
&lt;li&gt;Address validation records proving due diligence&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You're not preparing for audits — you're always ready. Export complete documentation on demand, proving compliance for every transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Platform Investment Decision
&lt;/h2&gt;

&lt;p&gt;Legacy platforms force tradeoffs: Shopify is easy but inflexible. Magento is powerful but expensive and complex. WooCommerce is customizable but scales poorly.&lt;/p&gt;

&lt;p&gt;Medusa eliminates these tradeoffs: open-source flexibility, modern architecture, enterprise capabilities without enterprise costs. When paired with Avalara's tax automation, you get infrastructure that grows with your business.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale Without Technical Debt
&lt;/h2&gt;

&lt;p&gt;Most e-commerce businesses outgrow their first platform. They've built workarounds for limitations, accumulated technical debt, and face expensive migration projects. This is avoidable.&lt;/p&gt;

&lt;p&gt;Starting with proper infrastructure—headless architecture, real-time tax automation, complete compliance documentation—means your platform grows with you instead of holding you back.&lt;/p&gt;

&lt;p&gt;Tax compliance isn't exciting, but it's foundational. Get it right from the start, and it becomes invisible infrastructure. Get it wrong, and it becomes an expensive barrier to growth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Need help with Avalara integration?
&lt;/h2&gt;

&lt;p&gt;Looking to implement automated tax compliance with Avalara in your Medusa store? u11d specializes in e-commerce integrations and tax automation solutions. We can help you set up, customize, and optimize your Avalara integration for maximum compliance and efficiency. Our team has extensive experience with both Medusa e-commerce platforms and Avalara tax systems, ensuring seamless integration that meets your specific business requirements.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://u11d.com/contact/" rel="noopener noreferrer"&gt;Contact Us&lt;/a&gt;&lt;/p&gt;

</description>
      <category>avalara</category>
      <category>medusa</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>A Practical Guide to Scaling Medusa with Kubernetes Autoscalers</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Wed, 29 Oct 2025 09:56:36 +0000</pubDate>
      <link>https://dev.to/u11d/a-practical-guide-to-scaling-medusa-with-kubernetes-autoscalers-4hmj</link>
      <guid>https://dev.to/u11d/a-practical-guide-to-scaling-medusa-with-kubernetes-autoscalers-4hmj</guid>
      <description>&lt;p&gt;As your &lt;strong&gt;Medusa.js e-commerce platform&lt;/strong&gt; grows, performance and reliability depend on how well it scales under load. Kubernetes provides native tools like the &lt;strong&gt;Horizontal Pod Autoscaler (HPA)&lt;/strong&gt; and &lt;strong&gt;KEDA&lt;/strong&gt; to automatically adjust resources based on real-time demand.&lt;br&gt;
In this guide, you’ll learn how to configure &lt;strong&gt;Medusa for horizontal scaling in Kubernetes&lt;/strong&gt;, using &lt;strong&gt;Prometheus&lt;/strong&gt;, &lt;strong&gt;cAdvisor&lt;/strong&gt;, and &lt;strong&gt;HPA&lt;/strong&gt; - ensuring your store remains responsive even during peak traffic periods.&lt;/p&gt;
&lt;h2&gt;
  
  
  Prerequisites for Medusa Autoscaling on Kubernetes
&lt;/h2&gt;

&lt;p&gt;Before implementing autoscaling, ensure that your monitoring and metric systems are in place.&lt;/p&gt;
&lt;h3&gt;
  
  
  Required Tools: cAdvisor, Prometheus, and KEDA
&lt;/h3&gt;

&lt;p&gt;To make HPA and KEDA work efficiently, you’ll need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;cAdvisor&lt;/strong&gt; – collects container-level CPU and memory metrics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus&lt;/strong&gt; – scrapes, stores, and visualizes time-series metrics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus Adapter or KEDA&lt;/strong&gt; – exposes those metrics to the HPA.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This setup is essential to achieve a reliable, responsive scaling mechanism that monitors pod and container utilization with fine-grained metric resolution (ideally 1 second or less).&lt;/p&gt;
&lt;h2&gt;
  
  
  Setting Up Metrics for HPA
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Configuring cAdvisor for container-level metrics
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;cAdvisor (Container Advisor)&lt;/strong&gt; is a running daemon that provides per-container resource usage data. It collects and exports information about all containers running on a host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key configuration tips:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Avoid collecting all metrics to reduce resource consumption.&lt;/li&gt;
&lt;li&gt;Adjust collection intervals according to your scaling sensitivity.
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--allow_dynamic_housekeeping=false&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--housekeeping_interval=1s&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;--max_housekeeping_interval=2s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;These flags ensure you get up-to-date metrics while keeping overhead low.&lt;/p&gt;
&lt;h3&gt;
  
  
  Prometheus Configuration for Scraping cAdvisor Data
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Prometheus&lt;/strong&gt; collects metrics from cAdvisor and stores them for HPA or KEDA to consume. Below is a sample configuration to scrape and relabel container metrics efficiently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cadvisor&lt;/span&gt;
    &lt;span class="na"&gt;scrape_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1s&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cadvisor.cadvisor.svc.cluster.local:8080"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;metric_relabel_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;container_label_io_kubernetes_pod_namespace&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;namespace&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;container_label_io_kubernetes_pod_name&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pod&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;container_label_io_kubernetes_container_name&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;container&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;source_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;container_label_io_kubernetes_pod_node_name&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;target_label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;container_label_.*&lt;/span&gt;
        &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;labeldrop&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; Use a 1-second scrape interval for real-time scaling accuracy, but monitor your Prometheus load — frequent scrapes can impact cluster performance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Using Prometheus Adapter to Expose Custom Metrics
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Prometheus Adapter&lt;/strong&gt; acts as a bridge between Prometheus and the Kubernetes HPA. It translates Prometheus metrics into Kubernetes-readable custom metrics that HPA can act upon.&lt;/p&gt;

&lt;p&gt;Example configuration for CPU and memory metrics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;containerQuery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;sum by (&amp;lt;&amp;lt;.GroupBy&amp;gt;&amp;gt;) (&lt;/span&gt;
        &lt;span class="s"&gt;rate(container_cpu_usage_seconds_total{container!="",&amp;lt;&amp;lt;.LabelMatchers&amp;gt;&amp;gt;}[5s])&lt;/span&gt;
      &lt;span class="s"&gt;)&lt;/span&gt;
    &lt;span class="na"&gt;nodeQuery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;sum  by (&amp;lt;&amp;lt;.GroupBy&amp;gt;&amp;gt;) (&lt;/span&gt;
        &lt;span class="s"&gt;rate(node_cpu_seconds_total{mode!="idle",mode!="iowait",mode!="steal",&amp;lt;&amp;lt;.LabelMatchers&amp;gt;&amp;gt;}[3m])&lt;/span&gt;
      &lt;span class="s"&gt;)&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;overrides&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;node&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node&lt;/span&gt;
        &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;namespace&lt;/span&gt;
        &lt;span class="na"&gt;pod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pod&lt;/span&gt;
    &lt;span class="na"&gt;containerLabel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;container&lt;/span&gt;
  &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;containerQuery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;sum by (&amp;lt;&amp;lt;.GroupBy&amp;gt;&amp;gt;) (&lt;/span&gt;
        &lt;span class="s"&gt;avg_over_time(container_memory_working_set_bytes{container!="",&amp;lt;&amp;lt;.LabelMatchers&amp;gt;&amp;gt;}[5s])&lt;/span&gt;
      &lt;span class="s"&gt;)&lt;/span&gt;
    &lt;span class="na"&gt;nodeQuery&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;sum by (&amp;lt;&amp;lt;.GroupBy&amp;gt;&amp;gt;) (&lt;/span&gt;
        &lt;span class="s"&gt;avg_over_time(node_memory_MemTotal_bytes{&amp;lt;&amp;lt;.LabelMatchers&amp;gt;&amp;gt;}[3m])&lt;/span&gt;
        &lt;span class="s"&gt;-&lt;/span&gt;
        &lt;span class="s"&gt;avg_over_time(node_memory_MemAvailable_bytes{&amp;lt;&amp;lt;.LabelMatchers&amp;gt;&amp;gt;}[3m])&lt;/span&gt;
      &lt;span class="s"&gt;)&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;overrides&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;node&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node&lt;/span&gt;
        &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;namespace&lt;/span&gt;
        &lt;span class="na"&gt;pod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pod&lt;/span&gt;
    &lt;span class="na"&gt;containerLabel&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;container&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This configuration exposes per-pod resource metrics that HPA can use to make scaling decisions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scaling Medusa with KEDA
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;How KEDA Integrates with Kubernetes HPA&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KEDA (Kubernetes Event-Driven Autoscaler)&lt;/strong&gt; enhances Kubernetes autoscaling by allowing scaling based on external events - for example, message queue depth, API requests, or Prometheus metrics.&lt;/p&gt;

&lt;p&gt;KEDA works alongside HPA to provide &lt;strong&gt;fine-grained, event-driven scaling&lt;/strong&gt; for your Medusa backend.&lt;/p&gt;

&lt;p&gt;Below is a sample configuration for scaling Medusa using KEDA with Prometheus as the metric source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keda.sh/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ScaledObject&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;medusa-backend&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scaleTargetRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;medusa-backend&lt;/span&gt;
  &lt;span class="na"&gt;pollingInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;cooldownPeriod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;60&lt;/span&gt;
  &lt;span class="na"&gt;minReplicaCount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
  &lt;span class="na"&gt;maxReplicaCount&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prometheus&lt;/span&gt;
      &lt;span class="na"&gt;metricType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AverageValue&lt;/span&gt;
      &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;serverAddress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;http://prometheus-server.default.svc.cluster.local:80&amp;gt;&lt;/span&gt;
        &lt;span class="na"&gt;metricName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;avg_cpu_usage&lt;/span&gt;
        &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.5"&lt;/span&gt;
        &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;sum by (pod) (rate(container_cpu_usage_seconds_total{pod=~"medusa-backend-.*", container!=""}[5s]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This YAML defines a &lt;strong&gt;ScaledObject&lt;/strong&gt; that dynamically adjusts Medusa’s replica count based on CPU usage metrics scraped by Prometheus.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fine-Tuning Kubernetes HPA
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;kube-controller-manager Parameters for Faster Scaling&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;kube-controller-manager&lt;/strong&gt; is a core Kubernetes component that controls how quickly HPA reacts to metric changes.&lt;/p&gt;

&lt;p&gt;By tuning its parameters, you can make scaling nearly instantaneous - for example, achieving a &lt;strong&gt;5-second response time&lt;/strong&gt; to CPU utilization spikes.&lt;/p&gt;

&lt;p&gt;Ensure you’ve optimized:&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="nt"&gt;--horizontal-pod-autoscaler-sync-period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lowering this interval makes the autoscaler evaluate conditions more frequently, but note that some managed Kubernetes distributions may restrict access to these flags.&lt;/p&gt;

&lt;h3&gt;
  
  
  Visualizing Scaling with Grafana
&lt;/h3&gt;

&lt;p&gt;Grafana dashboards can help you track CPU utilization, pod counts, and scaling behavior in real time.&lt;br&gt;
Below is an example visualization showing how HPA scales pods in and out based on CPU load.&lt;/p&gt;

&lt;p&gt;This visualization also highlights the importance of minimizing &lt;strong&gt;container startup and readiness probe times&lt;/strong&gt;, which directly affect how quickly new replicas become active.&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%2Fo4f56aejpifi28446isw.webp" 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%2Fo4f56aejpifi28446isw.webp" alt="Grafana dashboards to track CPU utilization, pod counts, and scaling behavior in real time." width="800" height="239"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Best Practices and Common Pitfalls
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Optimize Startup Times and Readiness Probes&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ensure Medusa starts and becomes ready as quickly as possible.&lt;/li&gt;
&lt;li&gt;Use &lt;strong&gt;readiness probes&lt;/strong&gt; to signal when pods can receive traffic.&lt;/li&gt;
&lt;li&gt;Long startup times can delay scaling and degrade performance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Selecting the Right Scaling Metrics&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU utilization is common, but not always optimal.&lt;/li&gt;
&lt;li&gt;Consider custom business metrics (e.g., requests per second, queue depth).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Dealing with HPA Sync Delays&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The default HPA sync period is &lt;strong&gt;15 seconds&lt;/strong&gt;, which might be too slow for bursty workloads.&lt;/li&gt;
&lt;li&gt;Decreasing it improves responsiveness but can increase API traffic and controller load.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Implementing &lt;strong&gt;HPA and KEDA&lt;/strong&gt; for your &lt;strong&gt;Medusa.js e-commerce platform&lt;/strong&gt; ensures efficient scaling, better performance under load, and optimal resource utilization.&lt;/p&gt;

&lt;p&gt;Key takeaways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Always configure readiness probes and optimize startup times.&lt;/li&gt;
&lt;li&gt;Choose scaling metrics carefully - CPU isn’t always the best indicator.&lt;/li&gt;
&lt;li&gt;Monitor your autoscaler’s responsiveness using Grafana and Prometheus.&lt;/li&gt;
&lt;li&gt;Test your setup under realistic load scenarios to validate scaling behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With the right configuration, Kubernetes can make your Medusa deployment both &lt;strong&gt;resilient and self-scaling&lt;/strong&gt;, ensuring you’re always ready for traffic spikes.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ: Scaling Medusa in Kubernetes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q1: What is the best way to scale Medusa on Kubernetes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A1: Use Kubernetes HPA with metrics from Prometheus and cAdvisor, or event-driven scaling through KEDA for more flexibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q2: Does Medusa support autoscaling natively?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A2: Medusa itself doesn’t manage scaling, but it runs well in Kubernetes environments that use HPA or KEDA.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q3: Which metrics should I track for autoscaling?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A3: Start with CPU and memory usage; consider adding metrics like request rate, queue size, or API latency for advanced control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q4: How can I monitor Medusa’s scaling performance?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A4: Integrate Prometheus with Grafana dashboards to visualize pod utilization and replica changes in real time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q5: What’s the difference between HPA and KEDA?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A5: HPA relies on internal Kubernetes metrics, while KEDA extends it to external sources like Prometheus queries or event systems.&lt;/p&gt;

</description>
      <category>medusa</category>
      <category>kubernetes</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>Missing files in your Packer built image? You might be skipping graceful shutdowns</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Thu, 23 Oct 2025 11:04:49 +0000</pubDate>
      <link>https://dev.to/u11d/missing-files-in-your-packer-built-image-you-might-be-skipping-graceful-shutdowns-4ccj</link>
      <guid>https://dev.to/u11d/missing-files-in-your-packer-built-image-you-might-be-skipping-graceful-shutdowns-4ccj</guid>
      <description>&lt;p&gt;Are your files and folders missing after you run the newly created image? This is a common issue when building images with &lt;a href="https://developer.hashicorp.com/packer" rel="noopener noreferrer"&gt;Packer&lt;/a&gt;. It is a widely used tool for creating machine images in a repeatable and automated way. When using the &lt;a href="https://developer.hashicorp.com/packer/integrations/hashicorp/qemu/latest/components/builder/qemu" rel="noopener noreferrer"&gt;&lt;code&gt;QEMU builder&lt;/code&gt;&lt;/a&gt; to generate local or CI-friendly images, it's common to upload files with the&amp;nbsp;&lt;a href="https://developer.hashicorp.com/packer/docs/provisioners/file" rel="noopener noreferrer"&gt;&lt;code&gt;file provisioner&lt;/code&gt;&lt;/a&gt; and configure systems using&amp;nbsp;&lt;a href="https://developer.hashicorp.com/packer/docs/provisioners/shell" rel="noopener noreferrer"&gt;&lt;code&gt;shell provisioner&lt;/code&gt;&lt;/a&gt;. One often overlooked but critical part of this workflow is how the virtual machine shuts down after provisioning. Without a proper shutdown, the resulting image can be unstable, incomplete, or even unbootable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Let’s see why &lt;strong&gt;graceful shutdowns&lt;/strong&gt; matter.
&lt;/h3&gt;

&lt;p&gt;After provisioning completes, &lt;a href="https://developer.hashicorp.com/packer" rel="noopener noreferrer"&gt;Packer&lt;/a&gt; snapshots the virtual disk to produce the final image. If the guest operating system is not properly shut down before this snapshot, you risk file system corruption, lost configuration changes, services failing to start, or persistent files missing entirely - especially those written during the final steps of provisioning. These issues often go unnoticed until the image is deployed in staging or production environments, where the consequences are far more disruptive.&lt;/p&gt;

&lt;p&gt;By default, the&amp;nbsp;&lt;code&gt;shutdown_command&lt;/code&gt;&amp;nbsp;is set to an empty string (&lt;code&gt;""&lt;/code&gt;). This means &lt;a href="https://developer.hashicorp.com/packer" rel="noopener noreferrer"&gt;Packer&lt;/a&gt; does not attempt a clean shutdown unless you explicitly configure one. Instead, the virtual machine is forcefully terminated, which is equivalent to abruptly cutting power on a physical machine - a common cause of data loss and image inconsistency.&lt;/p&gt;

&lt;p&gt;To prevent this, you must define a&amp;nbsp;&lt;code&gt;shutdown_command&lt;/code&gt;&amp;nbsp;that instructs the OS to shut down safely after provisioning. This ensures that services have time to stop, file operations complete, and the system reaches a stable state before the image is captured.&lt;/p&gt;

&lt;h3&gt;
  
  
  Correct usage of&amp;nbsp;&lt;code&gt;shutdown_command&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;A clean shutdown requires a properly formed command with the necessary privileges, and the exact command you use depends on the operating system inside the virtual machine. Most Linux distributions support the&amp;nbsp;&lt;code&gt;shutdown&lt;/code&gt;&amp;nbsp;command, but the syntax, required privileges, and available tools can vary slightly.&lt;/p&gt;

&lt;p&gt;Here’s a reliable example in HCL2 for a Debian/Ubuntu based image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="err"&gt;...&lt;/span&gt;
&lt;span class="nx"&gt;shutdown_command&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"echo 'packer' | sudo --stdin shutdown --poweroff now"&lt;/span&gt;
&lt;span class="err"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This command assumes the use of a user with password-based&amp;nbsp;&lt;code&gt;sudo&lt;/code&gt;&amp;nbsp;access. It triggers a privileged system shutdown, allowing services to stop cleanly and file buffers to flush before power-off. The&amp;nbsp;&lt;code&gt;--poweroff&lt;/code&gt;&amp;nbsp;flag ensures the machine actually powers off, which is essential for clean termination when using virtual hardware emulation. Adjust the password to match your base image setup.&lt;/p&gt;

&lt;p&gt;If you're using a different base image - such as CentOS, RHEL, or Alpine - the shutdown binary might be located elsewhere, require different flags, or use an alternative like&amp;nbsp;&lt;code&gt;poweroff&lt;/code&gt;&amp;nbsp;or&amp;nbsp;&lt;code&gt;halt&lt;/code&gt;. You may also need to configure&amp;nbsp;&lt;code&gt;sudoers&lt;/code&gt;&amp;nbsp;to avoid prompts or password issues during provisioning.&lt;/p&gt;

&lt;p&gt;Always test the shutdown command interactively inside a VM before inserting it into your template. It should work silently and reliably in the automated provisioning context.&lt;/p&gt;

&lt;h3&gt;
  
  
  Conclusion
&lt;/h3&gt;

&lt;p&gt;To sum up, graceful shutdowns are a necessary safeguard when building machine images with the &lt;a href="https://developer.hashicorp.com/packer/integrations/hashicorp/qemu/latest/components/builder/qemu" rel="noopener noreferrer"&gt;&lt;code&gt;QEMU builder&lt;/code&gt;&lt;/a&gt; in &lt;a href="https://developer.hashicorp.com/packer" rel="noopener noreferrer"&gt;Packer&lt;/a&gt;. Skipping this step or relying on Packer’s default behavior (which can forcibly terminate the VM) introduces the risk of unstable and corrupted images. By using a proper&amp;nbsp;&lt;code&gt;shutdown_command&lt;/code&gt;, you ensure the system shuts down in a controlled way, preserving the integrity of the file system and producing a clean, bootable image.&lt;/p&gt;

&lt;p&gt;This practice is simple to implement and greatly improves reliability in both development and production environments. If you're managing CI pipelines or building images for downstream automation, it's a foundational step that prevents subtle and costly failures later in the lifecycle.&lt;/p&gt;

</description>
      <category>packer</category>
      <category>qemu</category>
    </item>
    <item>
      <title>Headless CMS in E-Commerce: How to Integrate Medusa for Scalable, Content-Rich Online Stores</title>
      <dc:creator>uninterrupted</dc:creator>
      <pubDate>Wed, 01 Oct 2025 08:03:00 +0000</pubDate>
      <link>https://dev.to/u11d/headless-cms-in-e-commerce-how-to-integrate-medusa-for-scalable-content-rich-online-stores-4kkd</link>
      <guid>https://dev.to/u11d/headless-cms-in-e-commerce-how-to-integrate-medusa-for-scalable-content-rich-online-stores-4kkd</guid>
      <description>&lt;p&gt;Modern e-commerce isn’t just about selling products; it’s about delivering dynamic, content-driven experiences across multiple channels. To achieve this, businesses lean on &lt;strong&gt;Content Management Systems (CMS)&lt;/strong&gt; for structured, scalable content workflows. With the emergence of headless commerce engines like Medusa.js and modern framework like Next.js, integrating a CMS has become more streamlined and customizable than ever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defining CMS in the E-Commerce Stack
&lt;/h2&gt;

&lt;p&gt;A&amp;nbsp;Content Management System (CMS)&amp;nbsp;is a tool that lets you create, edit, and organize website content without touching code.&lt;/p&gt;

&lt;p&gt;In e-commerce terms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It separates&amp;nbsp;content&amp;nbsp;(product descriptions, blog posts, banners) from your&amp;nbsp;storefront&amp;nbsp;(the part customers see and shop in).&lt;/li&gt;
&lt;li&gt;Editors and marketers can add or update pages in minutes without breaking anything.&lt;/li&gt;
&lt;li&gt;Developers don’t have to be “content gatekeepers” — the CMS is the safe workspace for all non-technical updates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Think of it as&amp;nbsp;Google Docs for your website content, but integrated into your online store.&lt;/p&gt;

&lt;h2&gt;
  
  
  Medusa.js: Architecture and Integration
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://medusajs.com" rel="noopener noreferrer"&gt;Medusa.js&lt;/a&gt; is an open-source, Node.js-based headless commerce engine. Unlike traditional monolithic platforms (e.g. Magento, Shopify), Medusa.js separates the commerce backend from the frontend, exposing commerce logic through APIs. This architecture supports integration with any frontend framework (e.g. Next.js) or CMS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Pair Medusa.js with a CMS?
&lt;/h3&gt;

&lt;p&gt;Medusa.js powers the&amp;nbsp;transactional&amp;nbsp;side of your storefront. A CMS adds that storytelling layer, and&amp;nbsp;storefront (e.g. Next.js)&amp;nbsp;is the perfect glue that pulls both content and commerce into a seamless experience.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Blog posts&lt;/strong&gt;&amp;nbsp;— CMS stores articles, Next.js fetches them alongside Medusa product data for a fully integrated shopping-and-reading experience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Landing pages&lt;/strong&gt;&amp;nbsp;— Launch seasonal campaigns in the CMS; Next.js dynamically renders them with real-time Medusa product data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rich product descriptions&lt;/strong&gt;&amp;nbsp;— Pull long-form text, videos, and extra images from the CMS while keeping inventory and pricing from Medusa.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SEO improvements&lt;/strong&gt;&amp;nbsp;— Use Next.js server-side rendering (SSR) or static site generation (SSG) to ensure Google sees your content instantly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Key Decision Factors When Choosing a CMS
&lt;/h2&gt;

&lt;p&gt;When selecting a CMS to pair with Medusa, consider the following trade-offs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cloud (SaaS) vs. Self-Hosted&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloud (SaaS)&lt;/strong&gt;: Ideal when your scale is small to medium or when you lack in-house DevOps expertise. Fast to set up, minimal maintenance, and predictable subscription costs. However, you trade off some control, and expenses can grow significantly as traffic, content volume, or user count increases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-Hosted&lt;/strong&gt;: Full control and deep customization, with the potential for lower raw hosting costs in the long term. But infrastructure setup, deployments, scaling, security, and ongoing maintenance require time and expertise — which translates directly into operational cost.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developer Friendliness vs. Ease of Use for Editors&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;Some CMSs (like &lt;strong&gt;Payload&lt;/strong&gt;) are highly flexible and API-driven, offering developers full control but requiring training for non-technical editors.&lt;/li&gt;
&lt;li&gt;Others (like &lt;strong&gt;Strapi&lt;/strong&gt;) strike a balance, with strong developer features and a user-friendly admin panel.&lt;/li&gt;
&lt;li&gt;Platforms like &lt;strong&gt;Sanity&lt;/strong&gt; and &lt;strong&gt;Contentful&lt;/strong&gt; lean heavily toward editor experience, prioritizing usability and real-time collaboration.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Community and Plugins&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;A strong community speeds up problem-solving and provides a library of ready-to-use plugins.&lt;/li&gt;
&lt;li&gt;For example, Strapi’s plugin ecosystem includes tools for image optimization, role-based permissions, analytics, and more.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;CMS Selection Matrix for Medusa.js Integration&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;CMS&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Key Features&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Strapi&lt;/td&gt;
&lt;td&gt;Open-source (self-hosted or cloud)&lt;/td&gt;
&lt;td&gt;Customizable, large OSS community, plugin ecosystem&lt;/td&gt;
&lt;td&gt;Teams wanting balance between dev control and editor usability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payload&lt;/td&gt;
&lt;td&gt;Open-source (self-hosted or cloud)&lt;/td&gt;
&lt;td&gt;Developer-centric, clean editor, powerful APIs&lt;/td&gt;
&lt;td&gt;Dev-heavy teams building tailored workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sanity&lt;/td&gt;
&lt;td&gt;Headless SaaS&lt;/td&gt;
&lt;td&gt;Real-time collaboration, content portability&lt;/td&gt;
&lt;td&gt;Content-heavy teams with remote collaboration needs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contentful&lt;/td&gt;
&lt;td&gt;Headless SaaS&lt;/td&gt;
&lt;td&gt;Enterprise-ready, robust APIs, scalable&lt;/td&gt;
&lt;td&gt;Large organizations needing stability and global delivery&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Example Implementation: Medusa + Strapi + Next.js
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Medusa.js&lt;/strong&gt;: Manages catalog, checkout, orders, and authentication workflows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strapi&lt;/strong&gt;: Controls homepage banners, blog content, landing pages, and SEO metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt;: Consumes APIs from both Medusa and Strapi to render the frontend.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This decoupled structure allows content managers and developers to work in parallel, ensuring rapid deployment and updates across both commerce and content layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Combining a headless CMS with Medusa.js and modern storefront framework enables highly customizable, scalable, and performant e-commerce platforms. This architecture decouples content and commerce, allowing independent optimization and rapid iteration. As the industry moves toward open, API-driven systems, Medusa.js and compatible CMS platforms provide a robust foundation for next-generation digital commerce.&lt;/p&gt;

</description>
      <category>medusa</category>
      <category>strapi</category>
      <category>nextjs</category>
      <category>payload</category>
    </item>
  </channel>
</rss>
