<?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: Ahmed Mahmoud</title>
    <description>The latest articles on DEV Community by Ahmed Mahmoud (@ahmed_mahmoud360).</description>
    <link>https://dev.to/ahmed_mahmoud360</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%2F656404%2F01b9474b-ca4f-4578-a15e-36a90ad96c82.jpeg</url>
      <title>DEV Community: Ahmed Mahmoud</title>
      <link>https://dev.to/ahmed_mahmoud360</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ahmed_mahmoud360"/>
    <language>en</language>
    <item>
      <title>Database Connections in Serverless: Field Notes on Pool Math, PgBouncer Transaction Mode, and the Singleton HMR Kept Recreating</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Fri, 28 Aug 2026 06:00:12 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/database-connections-in-serverless-field-notes-on-pool-math-pgbouncer-transaction-mode-and-the-1bgb</link>
      <guid>https://dev.to/ahmed_mahmoud360/database-connections-in-serverless-field-notes-on-pool-math-pgbouncer-transaction-mode-and-the-1bgb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; A database connection pool in a serverless deployment is per-instance, not per-application. The real ceiling is concurrent instances multiplied by pool size, which is why the fix is almost never a bigger database — it is a connection pooler in transaction mode plus a driver configured to survive one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I hit &lt;code&gt;FATAL: sorry, too many clients already&lt;/code&gt; on a Next.js app whose traffic would not have troubled a single Postgres box in 2005. The database was idle. The application code was fine. What was not fine was my mental model: I had configured one pool with a sensible &lt;code&gt;max&lt;/code&gt; and assumed that number described the deployment. It described one function instance.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A connection pool is a cache of open TCP connections living inside one Node.js process. Total connections opened by a serverless app are roughly concurrent instances multiplied by each pool's &lt;code&gt;max&lt;/code&gt;, not the &lt;code&gt;max&lt;/code&gt; you configured.&lt;/li&gt;
&lt;li&gt;PostgreSQL ships with &lt;code&gt;max_connections = 100&lt;/code&gt; by default and reserves three for superusers, and every connection is a separate backend process with its own memory.&lt;/li&gt;
&lt;li&gt;PgBouncer in &lt;code&gt;transaction&lt;/code&gt; mode multiplexes many clients onto few server connections, but it discards session state: prepared statements, &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt;, session-level &lt;code&gt;SET&lt;/code&gt;, and advisory locks held between transactions all break.&lt;/li&gt;
&lt;li&gt;Drizzle with &lt;code&gt;postgres.js&lt;/code&gt; needs &lt;code&gt;prepare: false&lt;/code&gt; behind a transaction-mode pooler; Prisma needs &lt;code&gt;?pgbouncer=true&lt;/code&gt; plus a separate &lt;code&gt;directUrl&lt;/code&gt; for migrations.&lt;/li&gt;
&lt;li&gt;Vercel Fluid Compute reuses one instance across concurrent requests, so &lt;code&gt;max: 1&lt;/code&gt; now serializes handlers instead of protecting the database.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does my serverless app run out of Postgres connections?
&lt;/h2&gt;

&lt;p&gt;Because each function instance runs its own Node.js process holding its own pool, so the connections you actually open are concurrent instances multiplied by each pool's &lt;code&gt;max&lt;/code&gt;. Nothing about a pool is shared between processes. Ten instances configured with &lt;code&gt;max: 10&lt;/code&gt; is one hundred connections, not ten.&lt;/p&gt;

&lt;p&gt;That product collides with a hard server-side limit. PostgreSQL defaults to &lt;code&gt;max_connections = 100&lt;/code&gt;, holds three back for superuser access, and spawns a separate backend process per connection. The failure is load-shaped: a traffic spike opens more instances, each opens its own pool, and Postgres answers with &lt;code&gt;FATAL: sorry, too many clients already&lt;/code&gt; while database CPU stays flat.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did Fluid Compute change about connection pooling?
&lt;/h2&gt;

&lt;p&gt;Fluid Compute reuses a single function instance across concurrent requests, so one module-scope pool is shared by several in-flight requests inside the same process. The classic advice — set &lt;code&gt;max: 1&lt;/code&gt;, because an instance only ever handles one request — is actively harmful under that model: a pool of one serializes concurrent handlers behind a single connection.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/db.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;Pool&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;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Module scope: created once per instance, reused by every request&lt;/span&gt;
&lt;span class="c1"&gt;// that instance serves, including concurrent ones under Fluid Compute.&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;pool&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;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&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;DATABASE_URL&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;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                      &lt;span class="c1"&gt;// per instance, not per deployment&lt;/span&gt;
  &lt;span class="na"&gt;idleTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// hand connections back to the pooler&lt;/span&gt;
  &lt;span class="na"&gt;connectionTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_000&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;
  
  
  Which PgBouncer pooling mode should I use?
&lt;/h2&gt;

&lt;p&gt;Transaction mode, for ordinary application traffic. PgBouncer is a lightweight proxy that multiplexes many client connections onto a small set of real server connections, and &lt;code&gt;pool_mode&lt;/code&gt; decides how long a client keeps one of them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Server connection held for&lt;/th&gt;
&lt;th&gt;What it breaks&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The whole client session&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;td&gt;Migrations, &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt;, long-lived workers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;transaction&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One transaction&lt;/td&gt;
&lt;td&gt;Prepared statements, session &lt;code&gt;SET&lt;/code&gt;, advisory locks outside a transaction, &lt;code&gt;WITH HOLD&lt;/code&gt; cursors&lt;/td&gt;
&lt;td&gt;Serverless application traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;statement&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One statement&lt;/td&gt;
&lt;td&gt;Everything above, plus multi-statement transactions&lt;/td&gt;
&lt;td&gt;Rare; sharded setups without transactions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Supabase now fronts Postgres with Supavisor rather than PgBouncer, and Neon and Amazon RDS Proxy ship their own implementations, but the mode semantics are identical everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did my prepared statements break behind the pooler?
&lt;/h2&gt;

&lt;p&gt;Because a protocol-level prepared statement is session state on one server connection, and transaction mode may hand you a different connection for the next transaction. The symptom is a pair of errors alternating under load: &lt;code&gt;prepared statement "s1" already exists&lt;/code&gt; and &lt;code&gt;prepared statement "s1" does not exist&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;PgBouncer 1.21 and later can track prepared statements in transaction mode when &lt;code&gt;max_prepared_statements&lt;/code&gt; is above zero — confirm your provider enables it before depending on it. Otherwise disable them in the driver. &lt;code&gt;node-postgres&lt;/code&gt; only uses named prepared statements when you explicitly name a query; &lt;code&gt;postgres.js&lt;/code&gt; prepares by default and needs &lt;code&gt;prepare: false&lt;/code&gt;; Prisma needs &lt;code&gt;?pgbouncer=true&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// postgres.js + Drizzle behind a transaction-mode pooler&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;postgres&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;postgres&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;drizzle&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;drizzle-orm/postgres-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;postgres&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;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// named statements do not survive transaction mode&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;5&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I stop dev HMR from opening a new pool on every save?
&lt;/h2&gt;

&lt;p&gt;Cache the pool on &lt;code&gt;globalThis&lt;/code&gt; in development. Next.js hot module replacement re-evaluates changed modules, so &lt;code&gt;new Pool()&lt;/code&gt; at module scope runs again on every save while the previous pool keeps its sockets open. Twenty saves is twenty live pools, and eventually the local database refuses connections.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/db.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;Pool&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;pg&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;globalForDb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;globalThis&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;Pool&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;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;globalForDb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pool&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;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;connectionString&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;DATABASE_URL&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;5&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;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;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;globalForDb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Which connection string should migrations use?
&lt;/h2&gt;

&lt;p&gt;The direct, session-mode connection string — never the transaction-mode pooler. Migrations take advisory locks and run DDL that must stay held across statements, and a transaction-mode pooler can route the next statement to a different backend where that lock does not exist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")         // pooled, transaction mode
  directUrl = env("DIRECT_DATABASE_URL")  // direct, session mode
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drizzle Kit takes the same split by pointing its config at the direct URL while the runtime client uses the pooled one. The rule generalises: &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt;, advisory locks used as a distributed mutex, and anything that sets a session variable belongs on the direct connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is an HTTP database driver the better choice?
&lt;/h2&gt;

&lt;p&gt;When your handler runs one self-contained statement and you would rather not manage a TCP pool at all. Neon's &lt;code&gt;@neondatabase/serverless&lt;/code&gt; package exposes &lt;code&gt;neon()&lt;/code&gt;, which sends a single SQL statement over HTTP — no pool to size, no connection to leak. The cost is that HTTP is stateless: no interactive transactions, no session settings. The same package ships a WebSocket-backed &lt;code&gt;Pool&lt;/code&gt; for cases that need real transactions.&lt;/p&gt;

&lt;p&gt;My rule is boring: HTTP driver for read handlers running one query, TCP pool through a transaction-mode pooler for multi-statement transactions, direct connection for migrations and workers.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How large should &lt;code&gt;max&lt;/code&gt; be in a serverless connection pool?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Small — start around three to five per instance, then watch the server-side connection count at peak concurrency. The number that matters is instances × &lt;code&gt;max&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need PgBouncer if I use Prisma?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. Prisma's client-side pool is per-instance like any other and does not coordinate across processes. Add &lt;code&gt;?pgbouncer=true&lt;/code&gt; so Prisma stops relying on named prepared statements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does transaction-mode pooling break database transactions?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. It pins one server connection for the full duration of a transaction. It breaks state that lives &lt;em&gt;between&lt;/em&gt; transactions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why does my local database run out of connections when production does not?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Almost always HMR creating a new pool on every file save. Cache the pool on &lt;code&gt;globalThis&lt;/code&gt; in development.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I use &lt;code&gt;LISTEN/NOTIFY&lt;/code&gt; from a serverless function?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Not through a transaction-mode pooler — the listening session is not preserved. Use a direct session connection on a long-lived process, or a real queue.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/serverless-database-connection-pooling-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/serverless-database-connection-pooling-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>serverless</category>
      <category>node</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Environment Variables in Next.js: Field Notes on NEXT_PUBLIC_ Inlining, the Secret That Almost Shipped, and Failing the Build with Zod</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Tue, 25 Aug 2026 06:00:11 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/environment-variables-in-nextjs-field-notes-on-nextpublic-inlining-the-secret-that-almost-3o33</link>
      <guid>https://dev.to/ahmed_mahmoud360/environment-variables-in-nextjs-field-notes-on-nextpublic-inlining-the-secret-that-almost-3o33</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; A &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; environment variable is not read at runtime — Next.js replaces every &lt;code&gt;process.env.NEXT_PUBLIC_*&lt;/code&gt; expression with a string literal at build time. That single fact explains the value that refuses to update without a rebuild, the dynamic lookup that returns &lt;code&gt;undefined&lt;/code&gt; in the browser, and why one build artifact cannot serve two environments.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An environment variable in Next.js is one name with two different lives. On the server it lives in &lt;code&gt;process.env&lt;/code&gt;, a real Node.js object read at request time. In the browser it does not exist at all: any variable prefixed with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; is copied into the JavaScript bundle as a literal during &lt;code&gt;next build&lt;/code&gt;, and everything else is stripped. I have watched the same three failures come out of that split on several projects this year — a stale API URL that survived a redeploy, a secret that nearly rode a prop into the HTML, and a missing variable that surfaced as a runtime crash instead of a failed build. These notes are the checklist I now run before the first deploy.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; variables are inlined into the client bundle at build time. Changing the value in your host's dashboard does nothing until the next build.&lt;/li&gt;
&lt;li&gt;Inlining is static text replacement. &lt;code&gt;process.env[name]&lt;/code&gt; with a dynamic key and &lt;code&gt;const { NEXT_PUBLIC_X } = process.env&lt;/code&gt; both return &lt;code&gt;undefined&lt;/code&gt; in the browser.&lt;/li&gt;
&lt;li&gt;Server-only variables are stripped from client bundles, so &lt;code&gt;process.env.SECRET&lt;/code&gt; in a Client Component is &lt;code&gt;undefined&lt;/code&gt;, not a leak. Secrets leak through props serialized into the RSC payload and through renaming a variable to &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;server-only&lt;/code&gt; package turns "a Client Component imported my secrets module" into a build error instead of a silent risk.&lt;/li&gt;
&lt;li&gt;Validate environment variables with a Zod schema at module load and import that module in &lt;code&gt;next.config.ts&lt;/code&gt;, so a missing variable fails &lt;code&gt;next build&lt;/code&gt; instead of the first production request.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why did changing my NEXT_PUBLIC_ variable do nothing?
&lt;/h2&gt;

&lt;p&gt;Because the value the browser sees was compiled into the JavaScript at the last build. During &lt;code&gt;next build&lt;/code&gt;, the bundler performs a find-and-replace: every static &lt;code&gt;process.env.NEXT_PUBLIC_API_URL&lt;/code&gt; expression becomes the string the variable held on the build machine at that moment. The deployed bundle contains the literal URL, not a lookup. Editing the variable in a dashboard, a Docker &lt;code&gt;-e&lt;/code&gt; flag, or &lt;code&gt;.env.production&lt;/code&gt; changes what the &lt;em&gt;next&lt;/em&gt; build will see — the running one is frozen.&lt;/p&gt;

&lt;p&gt;The replacement is textual, which produces a failure mode that looks like a bug in Next.js and is not:&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;// Replaced at build time with a string literal — works in the browser&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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;NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// undefined in the browser: inlining is static text replacement,&lt;/span&gt;
&lt;span class="c1"&gt;// and no process.env object exists at runtime to index into&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NEXT_PUBLIC_API_URL&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;url2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// Also undefined after bundling: destructuring is not a static reference&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;NEXT_PUBLIC_API_URL&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Server code has none of these restrictions. Inside a Server Component, a route handler, or a Server Action, &lt;code&gt;process.env&lt;/code&gt; is the live Node.js object, read at request time, dynamic keys and all.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does a server secret actually reach the browser?
&lt;/h2&gt;

&lt;p&gt;Not through &lt;code&gt;process.env&lt;/code&gt; — Next.js strips unprefixed variables from client bundles, so &lt;code&gt;process.env.STRIPE_SECRET_KEY&lt;/code&gt; in a Client Component evaluates to &lt;code&gt;undefined&lt;/code&gt;. The leaks I have actually seen take two other roads. The first is serialization: a Server Component reads a secret and passes it as a prop to a Client Component, and the value is embedded in the RSC payload inside the HTML response, visible in View Source. The second is the "fix" reflex: a developer sees &lt;code&gt;undefined&lt;/code&gt; in the browser, renames &lt;code&gt;API_SECRET&lt;/code&gt; to &lt;code&gt;NEXT_PUBLIC_API_SECRET&lt;/code&gt;, the error disappears, and the secret is now a string literal in a public JavaScript file.&lt;/p&gt;

&lt;p&gt;The cheap defence is the &lt;code&gt;server-only&lt;/code&gt; package — an empty module whose import fails the build if it ends up in the client graph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/secrets.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server-only&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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;stripeSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;webhookSecret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any module that touches secrets gets that import at the top. If a Client Component ever imports it, directly or through a chain, the build fails with a readable error instead of shipping. React's experimental taint API (&lt;code&gt;experimental_taintUniqueValue&lt;/code&gt;) covers the prop-serialization road as well, but &lt;code&gt;server-only&lt;/code&gt; is stable today and catches the common case.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I fail the build when a variable is missing?
&lt;/h2&gt;

&lt;p&gt;Parse the environment through a Zod schema in a module that runs during the build, and import every variable through it. A missing or malformed variable then stops &lt;code&gt;next build&lt;/code&gt; with a named error instead of surfacing as &lt;code&gt;undefined&lt;/code&gt; in whatever code happened to read it first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/env.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;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;NEXT_PUBLIC_API_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;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;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;DATABASE_URL&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;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;STRIPE_SECRET_KEY&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;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// Client vars must be referenced literally so the bundler can inline them&lt;/span&gt;
  &lt;span class="na"&gt;NEXT_PUBLIC_API_URL&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;NEXT_PUBLIC_API_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;Two details earn their place. The keys are listed explicitly instead of passing &lt;code&gt;process.env&lt;/code&gt; wholesale, because client-referenced variables must appear as literal member expressions for the bundler to inline them. And importing this module from &lt;code&gt;next.config.ts&lt;/code&gt; (&lt;code&gt;import './src/env';&lt;/code&gt;) forces the schema to execute at build time even if no route touches it. The &lt;code&gt;@t3-oss/env-nextjs&lt;/code&gt; package wraps the same idea with a server/client split.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which .env file wins, and which ones do I commit?
&lt;/h2&gt;

&lt;p&gt;Next.js loads &lt;code&gt;.env&lt;/code&gt; files itself — no &lt;code&gt;dotenv&lt;/code&gt; package needed — with a fixed precedence: an already-set shell variable beats every file, and more specific files beat general ones.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Loaded when&lt;/th&gt;
&lt;th&gt;Commit it?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.env&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every environment&lt;/td&gt;
&lt;td&gt;Yes — shared defaults&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.env.local&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Every environment except &lt;code&gt;NODE_ENV=test&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No — machine secrets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.env.development&lt;/code&gt; / &lt;code&gt;.env.production&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;When &lt;code&gt;NODE_ENV&lt;/code&gt; matches&lt;/td&gt;
&lt;td&gt;Yes — per-env defaults&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.env.development.local&lt;/code&gt; / &lt;code&gt;.env.production.local&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;When &lt;code&gt;NODE_ENV&lt;/code&gt; matches; beats other files&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;NODE_ENV&lt;/code&gt; itself is not free-form: Next.js recognises exactly &lt;code&gt;development&lt;/code&gt;, &lt;code&gt;production&lt;/code&gt;, and &lt;code&gt;test&lt;/code&gt;. &lt;code&gt;next dev&lt;/code&gt; forces the first; &lt;code&gt;next build&lt;/code&gt; and &lt;code&gt;next start&lt;/code&gt; force the second. A staging environment is therefore &lt;code&gt;NODE_ENV=production&lt;/code&gt; plus your own variable such as &lt;code&gt;APP_ENV=staging&lt;/code&gt;. One more sharp edge: &lt;code&gt;.env.local&lt;/code&gt; is deliberately ignored when &lt;code&gt;NODE_ENV=test&lt;/code&gt;, so test runs stay reproducible across machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can one build artifact serve both staging and production?
&lt;/h2&gt;

&lt;p&gt;For server-side variables, yes — they are read at request time, so the same Docker image can boot with different &lt;code&gt;DATABASE_URL&lt;/code&gt; values. For &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; variables, no — their values are already inside the JavaScript. On Vercel this stays invisible because every environment gets its own build with its own variables. A &lt;code&gt;output: 'standalone'&lt;/code&gt; Docker deployment that promotes the same image across environments exposes it immediately.&lt;/p&gt;

&lt;p&gt;Three honest ways out, in the order I try them: keep configuration server-side and let client code call same-origin paths (a rewrite that proxies &lt;code&gt;/api&lt;/code&gt; removes most reasons a browser needs an absolute URL); read the value in a Server Component at request time and pass it down as a prop; or accept one build per environment. What does not work is editing the variable and redeploying the same artifact — and it fails silently, because the old value keeps being served with no error anywhere.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Are my server-only variables exposed to the browser?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. Next.js strips variables without the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix from client bundles, so reading one in a Client Component returns &lt;code&gt;undefined&lt;/code&gt;. Exposure happens when a secret is passed as a prop into a Client Component or renamed to a &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; variable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I need the dotenv package in a Next.js project?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. Next.js loads &lt;code&gt;.env&lt;/code&gt;, &lt;code&gt;.env.local&lt;/code&gt;, and the &lt;code&gt;NODE_ENV&lt;/code&gt;-specific variants itself, in a documented order. Adding &lt;code&gt;dotenv&lt;/code&gt; on top usually just creates a second, conflicting load order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I set NODE_ENV to staging?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. Next.js recognises only &lt;code&gt;development&lt;/code&gt;, &lt;code&gt;production&lt;/code&gt;, and &lt;code&gt;test&lt;/code&gt;, and the CLI commands set it for you. Model staging as &lt;code&gt;NODE_ENV=production&lt;/code&gt; plus your own variable, for example &lt;code&gt;APP_ENV=staging&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why is &lt;code&gt;process.env[name]&lt;/code&gt; undefined in the browser?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Because inlining is a static build-time replacement of literal &lt;code&gt;process.env.NEXT_PUBLIC_*&lt;/code&gt; expressions. There is no &lt;code&gt;process.env&lt;/code&gt; object in the browser to index with a dynamic key, so only the literal form works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I get type-safe environment variables in TypeScript?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Export a Zod-validated &lt;code&gt;env&lt;/code&gt; object from one module and import it everywhere instead of touching &lt;code&gt;process.env&lt;/code&gt; directly. Augmenting the &lt;code&gt;ProcessEnv&lt;/code&gt; type gives autocomplete but no runtime guarantee; the schema gives both.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/nextjs-environment-variables-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/nextjs-environment-variables-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Webhooks in the Next.js App Router: Field Notes on Raw Bodies, Signature Verification, and Returning 200 Before the Work</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Mon, 24 Aug 2026 06:00:08 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/webhooks-in-the-nextjs-app-router-field-notes-on-raw-bodies-signature-verification-and-1bc6</link>
      <guid>https://dev.to/ahmed_mahmoud360/webhooks-in-the-nextjs-app-router-field-notes-on-raw-bodies-signature-verification-and-1bc6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; A webhook signature is an HMAC computed over the raw request bytes, so a handler that parses the body before verifying has already destroyed the evidence. In the Next.js App Router, &lt;code&gt;await req.text()&lt;/code&gt; inside a route handler returns those raw bytes — verify first, acknowledge fast, and do the real work idempotently after the response.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A webhook is an HTTP POST a provider sends to my server when something happens on their side — a payment settles, a repository receives a push, a subscription cancels. I have wired webhooks into several Next.js App Router apps this year, and every one of them broke in the same three places first: verifying a signature against a body I had already parsed, doing too much work before responding, and processing the same event twice. These notes are the checklist I now start from.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Webhook signatures — Stripe's &lt;code&gt;Stripe-Signature&lt;/code&gt;, GitHub's &lt;code&gt;X-Hub-Signature-256&lt;/code&gt; — are HMACs over the raw request bytes. Verify against &lt;code&gt;await req.text()&lt;/code&gt;, never a re-serialized &lt;code&gt;JSON.parse&lt;/code&gt; result.&lt;/li&gt;
&lt;li&gt;App Router route handlers do not pre-parse request bodies, so the Pages Router &lt;code&gt;bodyParser: false&lt;/code&gt; escape hatch is unnecessary — &lt;code&gt;req.text()&lt;/code&gt; is already the raw payload.&lt;/li&gt;
&lt;li&gt;Return a 2xx quickly. Providers treat a slow response as a failed delivery and retry it, so a slow handler races its own retries.&lt;/li&gt;
&lt;li&gt;Delivery is at-least-once and unordered: dedupe by event ID with a database unique constraint, and fetch the current object from the provider's API instead of trusting payload state.&lt;/li&gt;
&lt;li&gt;Compare signatures with &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt; and enforce a timestamp tolerance so a captured request cannot be replayed later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does my signature check fail on a genuine request?
&lt;/h2&gt;

&lt;p&gt;Because the signature was computed over the exact bytes the provider sent, and the handler is verifying different bytes. A webhook signature is a keyed hash — an HMAC — of the raw request body, produced with a shared signing secret. If I call &lt;code&gt;JSON.parse&lt;/code&gt; on the body and re-serialize it to verify, key order, whitespace, and unicode escaping can all change, and the HMAC no longer matches even though the request is genuine. The failure is silent and total: every event gets a 400, the provider retries, and the retry queue fills while the code looks correct.&lt;/p&gt;

&lt;p&gt;The App Router makes the correct version easy. A route handler receives the standard web &lt;code&gt;Request&lt;/code&gt; object, and Next.js does not pre-parse it — &lt;code&gt;await req.text()&lt;/code&gt; returns the payload byte-for-byte. The Pages Router needed &lt;code&gt;export const config = { api: { bodyParser: false } }&lt;/code&gt; for the same access; that configuration does nothing in the App Router.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/webhooks/stripe/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&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;stripe&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;stripe&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;Stripe&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;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawBody&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;               &lt;span class="c1"&gt;// raw bytes — parse AFTER verification&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stripe-signature&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;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="o"&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;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the provider ships a verification helper, I use it. &lt;code&gt;stripe.webhooks.constructEvent&lt;/code&gt; checks both the HMAC and the signed timestamp in one call and throws on either failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I verify a webhook signature without an SDK helper?
&lt;/h2&gt;

&lt;p&gt;Compute the HMAC yourself and compare in constant time. Two rules are non-negotiable. First, compare with &lt;code&gt;crypto.timingSafeEqual&lt;/code&gt;, because an ordinary string comparison returns early at the first differing character and leaks timing information an attacker can use to probe signatures byte by byte. Second, enforce a timestamp tolerance — most providers include a signed timestamp, and rejecting anything older than about five minutes stops a captured request from being replayed later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timingSafeEqual&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^sha256=/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// GitHub prefixes the hex digest&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;received&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One structural note: a webhook endpoint must be a route handler, not a Server Action. A Server Action is an RPC mechanism for my own application's frontend, addressed by framework-generated identifiers. A webhook needs a stable public POST URL with byte-level body access, and &lt;code&gt;app/api/webhooks/&amp;lt;provider&amp;gt;/route.ts&lt;/code&gt; is exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why should the handler return 200 before doing the real work?
&lt;/h2&gt;

&lt;p&gt;Because the provider treats a slow response as a failed delivery. Delivery timeouts are measured in seconds, and a handler that exceeds one gets marked failed and retried — so the slow handler ends up running concurrently with its own retry. Fulfillment logic that takes ten seconds guarantees every real event arrives at least twice.&lt;/p&gt;

&lt;p&gt;The shape I use now is verify, record, acknowledge, then work. On Vercel, &lt;code&gt;waitUntil&lt;/code&gt; from &lt;code&gt;@vercel/functions&lt;/code&gt; keeps the function instance alive after the response has been sent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;waitUntil&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;@vercel/functions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyAndParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&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;fresh&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;recordEventId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// unique constraint = dedupe&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;fresh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;processEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;     &lt;span class="c1"&gt;// runs after the response is sent&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;    &lt;span class="c1"&gt;// acknowledge in milliseconds&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where the work runs&lt;/th&gt;
&lt;th&gt;Ack speed&lt;/th&gt;
&lt;th&gt;On a crash&lt;/th&gt;
&lt;th&gt;Reach for it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Inline, before the response&lt;/td&gt;
&lt;td&gt;Slow — bounded by the work&lt;/td&gt;
&lt;td&gt;Provider retry re-delivers the event&lt;/td&gt;
&lt;td&gt;Trivial work: set a flag, update one row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;waitUntil&lt;/code&gt;, after the response&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Work is lost; event already acknowledged&lt;/td&gt;
&lt;td&gt;Losable side effects: cache warming, notifications&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queue or job row, separate worker&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Job survives and is retried&lt;/td&gt;
&lt;td&gt;Money, entitlements, anything you cannot lose&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The queue row is the only option I trust for money. &lt;code&gt;waitUntil&lt;/code&gt; work that dies in a crash was already acknowledged with a 200, and the provider will never resend it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I handle retries and out-of-order events?
&lt;/h2&gt;

&lt;p&gt;By assuming at-least-once delivery and no ordering, because both are documented provider behavior, not edge cases.&lt;/p&gt;

&lt;p&gt;Deduplication: every provider event carries a stable ID — &lt;code&gt;evt_…&lt;/code&gt; on a Stripe event, the &lt;code&gt;X-GitHub-Delivery&lt;/code&gt; header on a GitHub delivery. I insert that ID into a table with a unique constraint before processing; a constraint violation means the event was already handled, so the handler returns 200 and stops. A SELECT-then-INSERT check is a race under concurrent retries — the constraint is the lock.&lt;/p&gt;

&lt;p&gt;Ordering: I do not build state by applying payloads in arrival order. The event is a notification that something changed, not the change itself. For anything stateful I fetch the current object from the provider's API before writing, so a stale payload cannot overwrite newer state.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I test webhooks locally?
&lt;/h2&gt;

&lt;p&gt;A provider cannot reach &lt;code&gt;localhost&lt;/code&gt;, so local testing needs a bridge. Three options cover my use: a provider CLI that forwards events — &lt;code&gt;stripe listen --forward-to localhost:3000/api/webhooks/stripe&lt;/code&gt; prints a temporary signing secret for the session; a tunnel such as &lt;code&gt;cloudflared&lt;/code&gt; or &lt;code&gt;ngrok&lt;/code&gt; plus the provider dashboard's manual redelivery button; and signed fixtures in tests.&lt;/p&gt;

&lt;p&gt;The fixtures are the ones that pay rent. I capture one real payload, compute its HMAC with a test secret, and assert three things: the verifier accepts the valid pair, rejects a mutated body, and rejects an expired timestamp. That test catches the raw-body regression — someone adding a JSON middleware or moving the parse above the verify — before it ships and silently 400s every event.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can a Server Action be a webhook endpoint?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. A Server Action is invoked through framework-generated identifiers and is designed for your own application's components. A webhook provider needs a stable public POST URL with raw-body access, which is a route handler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need &lt;code&gt;bodyParser: false&lt;/code&gt; in the App Router?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. That option configures Pages Router API routes. An App Router route handler leaves the body untouched until you call &lt;code&gt;req.text()&lt;/code&gt;, &lt;code&gt;req.json()&lt;/code&gt;, or &lt;code&gt;req.formData()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What should I return for event types I do not handle?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Return 200. A non-2xx response tells the provider the delivery failed, so it retries events you will never process, and some providers disable an endpoint that keeps failing. Reserve 400 for signature failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What happens to events sent while my deployment was down?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Providers retry failed deliveries with backoff — Stripe retries for days — so short downtime is usually absorbed. For critical state I also run a periodic reconciliation job against the provider's API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I rotate a webhook secret without downtime?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Verify incoming signatures against both the old and the new secret during the rotation window. Providers like Stripe allow an old signing secret to stay active for an overlap period for exactly this reason.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/webhooks-nextjs-app-router-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/webhooks-nextjs-app-router-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webhooks</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>next/image in Next.js 16: Field Notes on LCP, the sizes Prop That Doubles Bandwidth, and What Optimization Costs</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Sat, 22 Aug 2026 13:36:14 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/nextimage-in-nextjs-16-field-notes-on-lcp-the-sizes-prop-that-doubles-bandwidth-and-what-3oh5</link>
      <guid>https://dev.to/ahmed_mahmoud360/nextimage-in-nextjs-16-field-notes-on-lcp-the-sizes-prop-that-doubles-bandwidth-and-what-3oh5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Using &lt;code&gt;next/image&lt;/code&gt; is not the same as having fast images. The component lazy-loads every image it renders unless you pass &lt;code&gt;priority&lt;/code&gt;, and a wrong &lt;code&gt;sizes&lt;/code&gt; prop makes a 400-pixel-wide card download the 3840-pixel candidate on a high-DPR screen.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;next/image&lt;/code&gt; is the Next.js built-in component that generates a responsive &lt;code&gt;srcset&lt;/code&gt;, converts source files to modern formats on demand, and reserves layout space before a byte arrives. I have used it on every Next.js project I have built, and I still spent an afternoon this month working out why a page full of &lt;code&gt;&amp;lt;Image&amp;gt;&lt;/code&gt; tags had a worse Largest Contentful Paint than the plain &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; markup it replaced. The layout-shift half is automatic. The bandwidth and latency half is not.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;next/image&lt;/code&gt; lazy-loads every image by default, including the one that is your Largest Contentful Paint element. Only the &lt;code&gt;priority&lt;/code&gt; prop opts an image out of lazy loading and adds a preload hint.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;sizes&lt;/code&gt; prop tells the browser how wide the image will render before CSS is applied. An &lt;code&gt;&amp;lt;Image fill&amp;gt;&lt;/code&gt; with no &lt;code&gt;sizes&lt;/code&gt; is treated as &lt;code&gt;100vw&lt;/code&gt;, which selects the largest candidate in the srcset.&lt;/li&gt;
&lt;li&gt;Next.js 16 removed the &lt;code&gt;images.domains&lt;/code&gt; option, so &lt;code&gt;images.remotePatterns&lt;/code&gt; is the only way to allow a remote host.&lt;/li&gt;
&lt;li&gt;Next.js 16 restricts the &lt;code&gt;quality&lt;/code&gt; prop to values listed in &lt;code&gt;images.qualities&lt;/code&gt;, which defaults to &lt;code&gt;[75]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;An image transformation is a unique combination of source image, width, quality and output format, so a wide &lt;code&gt;deviceSizes&lt;/code&gt; array multiplies both cost and cache misses.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why is my LCP still slow when I already use next/image?
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;next/image&lt;/code&gt; lazy-loads every image by default, including the one that is your Largest Contentful Paint element. Largest Contentful Paint measures when the biggest visible element finishes rendering. A lazy image is not requested until the browser has run layout and decided the image is near the viewport, so the preload scanner — which normally starts image downloads while the HTML is still being parsed — never sees it.&lt;/p&gt;

&lt;p&gt;The fix is one prop, applied to exactly one image per route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Image&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/image&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;hero&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;@/public/hero.jpg&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;Hero&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Image&lt;/span&gt;
      &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hero&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;        &lt;span class="c1"&gt;// static import: width, height and blurDataURL come for free&lt;/span&gt;
      &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;
      &lt;span class="na"&gt;priority&lt;/span&gt;          &lt;span class="c1"&gt;// no lazy loading, fetchpriority="high", preload hint in head&lt;/span&gt;
      &lt;span class="na"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"100vw"&lt;/span&gt;
      &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"w-full h-auto"&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 &lt;code&gt;priority&lt;/code&gt; prop does three things: it removes &lt;code&gt;loading="lazy"&lt;/code&gt;, sets &lt;code&gt;fetchpriority="high"&lt;/code&gt;, and emits a preload link in the document head. Marking six images &lt;code&gt;priority&lt;/code&gt; is the same as marking none, because six high-priority requests then compete for the same connection.&lt;/p&gt;

&lt;p&gt;Two related traps cost me time. First, &lt;code&gt;placeholder="blur"&lt;/code&gt; inlines a base64 data URI into the HTML, so a heavy &lt;code&gt;blurDataURL&lt;/code&gt; grows the document on the critical path. Second, an image rendered by a client component that only mounts after hydration cannot be preloaded at all, whatever you pass to &lt;code&gt;priority&lt;/code&gt; — the markup does not exist when the preload scanner runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the sizes prop actually do, and when does it double my bandwidth?
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;sizes&lt;/code&gt; attribute tells the browser how wide the image will be rendered, so it can pick a &lt;code&gt;srcset&lt;/code&gt; candidate before stylesheets are applied. Choose the candidate list badly and the browser downloads the biggest file you offered it.&lt;/p&gt;

&lt;p&gt;Without &lt;code&gt;sizes&lt;/code&gt;, Next.js emits a fixed 1x/2x srcset built from the &lt;code&gt;width&lt;/code&gt; you passed. With &lt;code&gt;sizes&lt;/code&gt;, it emits a full candidate list drawn from &lt;code&gt;images.deviceSizes&lt;/code&gt; (default &lt;code&gt;640, 750, 828, 1080, 1200, 1920, 2048, 3840&lt;/code&gt;) and &lt;code&gt;images.imageSizes&lt;/code&gt; (default &lt;code&gt;16, 32, 48, 64, 96, 128, 256, 384&lt;/code&gt;). The &lt;code&gt;fill&lt;/code&gt; prop with no &lt;code&gt;sizes&lt;/code&gt; is treated as &lt;code&gt;100vw&lt;/code&gt;, so on a 1920-pixel display at device pixel ratio 2 the browser asks for the 3840-pixel file — for a card that renders at 400 pixels.&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;// A product card image that is never wider than 400 CSS pixels.&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Image&lt;/span&gt;
  &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;fill&lt;/span&gt;
  &lt;span class="na"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px"&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule I now apply in review: an &lt;code&gt;&amp;lt;Image fill&amp;gt;&lt;/code&gt; with no &lt;code&gt;sizes&lt;/code&gt; prop is a defect, not a style preference. Verify it in the Chrome DevTools Network panel by comparing the transferred size of each image against the box it renders into.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I use fill or explicit width and height?
&lt;/h2&gt;

&lt;p&gt;Use explicit &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; whenever you know the intrinsic dimensions, because Next.js turns them into a CSS &lt;code&gt;aspect-ratio&lt;/code&gt; that reserves space and keeps Cumulative Layout Shift at zero. A static import supplies both dimensions and a generated &lt;code&gt;blurDataURL&lt;/code&gt; at build time, so it is the cheapest correct option for any asset in your repository.&lt;/p&gt;

&lt;p&gt;Reach for &lt;code&gt;fill&lt;/code&gt; only when the rendered box is decided by CSS and the source aspect ratio varies — user-uploaded avatars, CMS hero images, a masonry grid. &lt;code&gt;fill&lt;/code&gt; absolutely positions the image, so the parent needs &lt;code&gt;position: relative&lt;/code&gt; and a non-zero height, and cropping is your job through &lt;code&gt;object-fit&lt;/code&gt;. Every &lt;code&gt;fill&lt;/code&gt; image also needs a &lt;code&gt;sizes&lt;/code&gt; prop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed for images in Next.js 16?
&lt;/h2&gt;

&lt;p&gt;Next.js 16 tightened image configuration in three ways that break existing config files. The &lt;code&gt;images.domains&lt;/code&gt; option was removed in favour of &lt;code&gt;images.remotePatterns&lt;/code&gt;. The &lt;code&gt;quality&lt;/code&gt; prop is now restricted to values listed in &lt;code&gt;images.qualities&lt;/code&gt;, which defaults to &lt;code&gt;[75]&lt;/code&gt;. And &lt;code&gt;images.localPatterns&lt;/code&gt; lets you restrict which local paths the optimizer will accept, which matters because &lt;code&gt;/_next/image&lt;/code&gt; is a public endpoint that anyone can call with arbitrary parameters.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// images.domains was removed in Next.js 16. remotePatterns is the only form.&lt;/span&gt;
    &lt;span class="na"&gt;remotePatterns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cdn.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/products/**&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="c1"&gt;// Only these quality values are accepted. Anything else is rejected.&lt;/span&gt;
    &lt;span class="na"&gt;qualities&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;localPatterns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/assets/**&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;search&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="na"&gt;formats&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;image/avif&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;image/webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="c1"&gt;// Set this explicitly. A short TTL recomputes transformations you already paid for.&lt;/span&gt;
    &lt;span class="na"&gt;minimumCacheTTL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;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;/div&gt;



&lt;p&gt;SVG is still refused by the optimizer unless you set &lt;code&gt;dangerouslyAllowSVG: true&lt;/code&gt;, and that default is correct: an SVG is an executable document, and optimizing one from an untrusted host turns your own origin into the delivery vehicle for its scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should images.formats list AVIF or WebP first?
&lt;/h2&gt;

&lt;p&gt;List AVIF first when bandwidth is the constraint, and WebP first when first-request latency is. The &lt;code&gt;images.formats&lt;/code&gt; array is ordered by preference, and the optimizer picks the first entry the requesting browser accepts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;File size&lt;/th&gt;
&lt;th&gt;Encode cost&lt;/th&gt;
&lt;th&gt;Reach for it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AVIF&lt;/td&gt;
&lt;td&gt;Smaller than WebP at equal visual quality&lt;/td&gt;
&lt;td&gt;Noticeably slower to encode&lt;/td&gt;
&lt;td&gt;Images are cached and served many times; bytes dominate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebP&lt;/td&gt;
&lt;td&gt;Larger than AVIF, far smaller than JPEG&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Long-tail images with few hits, where every request is a cache miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JPEG/PNG source&lt;/td&gt;
&lt;td&gt;Largest&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Fallback only, for clients that accept neither&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The trade-off is uneven across your site. On a marketing page with five hero images that every visitor loads, AVIF encoding happens once and the smaller bytes win forever. On a catalogue with fifty thousand product photos where most are viewed once, the slower encode is on the critical path of a real user every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I keep image transformation cost bounded?
&lt;/h2&gt;

&lt;p&gt;An image transformation is a unique combination of source image, requested width, quality and output format, and each unique combination is computed and billed once before it is cached. That definition is the whole cost model: everything that multiplies the number of distinct combinations multiplies your bill.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trim &lt;code&gt;deviceSizes&lt;/code&gt;.&lt;/strong&gt; Eight default widths times two formats is sixteen possible transformations per source image. If your layout has three real breakpoints, list three widths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use one quality value.&lt;/strong&gt; Next.js 16 already forces you to declare them; declare one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raise &lt;code&gt;minimumCacheTTL&lt;/code&gt;.&lt;/strong&gt; Next.js honours an upstream &lt;code&gt;Cache-Control&lt;/code&gt; max-age when it is longer than &lt;code&gt;minimumCacheTTL&lt;/code&gt;, so a CMS that sends &lt;code&gt;no-store&lt;/code&gt; quietly defeats the cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;unoptimized&lt;/code&gt; on assets that are already optimized&lt;/strong&gt; — sprite sheets, small PNG icons, anything your build pipeline has already squeezed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch for cache-busting query strings.&lt;/strong&gt; A source URL with a changing token is a new source image every time, and therefore a new transformation every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When should I bypass next/image entirely?
&lt;/h2&gt;

&lt;p&gt;Three cases justify leaving the component behind. For art direction — a different crop on mobile than desktop — call &lt;code&gt;getImageProps()&lt;/code&gt; (stable since Next.js 15, previously &lt;code&gt;unstable_getImgProps&lt;/code&gt;) to obtain the generated &lt;code&gt;srcset&lt;/code&gt; and feed it into your own &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element or a CSS background. For a static export (&lt;code&gt;output: 'export'&lt;/code&gt;) there is no server to run the optimizer, so you must set &lt;code&gt;images.unoptimized: true&lt;/code&gt; or supply a custom loader. And if you already pay for an image CDN such as Cloudinary or imgix, point &lt;code&gt;images.loaderFile&lt;/code&gt; at it rather than optimizing twice.&lt;/p&gt;

&lt;p&gt;Self-hosting has one more requirement worth stating plainly: the built-in optimizer needs the &lt;code&gt;sharp&lt;/code&gt; package installed. The pure-JavaScript fallback was removed in earlier releases, so a self-hosted deployment without &lt;code&gt;sharp&lt;/code&gt; will fail to optimize rather than silently degrade.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Should I add priority to every above-the-fold image?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. Add &lt;code&gt;priority&lt;/code&gt; to the single image most likely to be the Largest Contentful Paint element. Multiple high-priority images compete for bandwidth and delay the one that actually determines the metric.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why does my optimized image look soft on a Retina screen?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Next.js never upscales beyond the intrinsic size of the source file. If the browser requests a 1600-pixel candidate and the source is 800 pixels wide, you get 800 pixels rendered into a 1600-pixel box. Replace the source asset; no configuration fixes it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I need sharp when self-hosting Next.js?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes. The built-in image optimizer requires the &lt;code&gt;sharp&lt;/code&gt; package outside of Vercel, where the platform provides its own optimization layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does next/image work with output: 'export'?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Not with the default loader, because a static export has no server. Set &lt;code&gt;images.unoptimized: true&lt;/code&gt; to emit plain &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tags, or configure &lt;code&gt;images.loaderFile&lt;/code&gt; to point at an external image CDN.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is AVIF always the better choice?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. AVIF produces smaller files than WebP at comparable quality but takes measurably longer to encode, so on images with low cache-hit rates the encode time lands on a real user's first request.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/nextjs-image-optimization-lcp-sizes-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/nextjs-image-optimization-lcp-sizes-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>performance</category>
      <category>webdev</category>
      <category>react</category>
    </item>
    <item>
      <title>Rate Limiting in the Next.js App Router: Field Notes on Middleware, Redis, and the Server Action That Looks Like Every Other POST</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Wed, 19 Aug 2026 06:00:14 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/rate-limiting-in-the-nextjs-app-router-field-notes-on-middleware-redis-and-the-server-action-2lh2</link>
      <guid>https://dev.to/ahmed_mahmoud360/rate-limiting-in-the-nextjs-app-router-field-notes-on-middleware-redis-and-the-server-action-2lh2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; An in-memory rate limiter is wrong on serverless because every function instance keeps its own counter, so a limit of 10 requests per minute becomes 10 requests per minute &lt;em&gt;per instance&lt;/em&gt;. The second trap is that every Next.js Server Action POSTs to the URL of the page that called it, so path-based limiting in middleware cannot tell one action from another.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A rate limiter is a counter with a deadline: allow N requests per identity per time window and reject the rest with HTTP 429. I have written that in ten lines before and been happy with it. Moving the same idea into a Next.js 16 App Router project on Vercel broke it in three places I did not expect — where the counter lives, who counts as an identity, and how you attach a limit to a Server Action that has no URL of its own.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;An in-memory &lt;code&gt;Map&lt;/code&gt; rate limiter is per-instance on serverless. With ten warm function instances, a 10-requests-per-minute limit permits 100 requests per minute.&lt;/li&gt;
&lt;li&gt;Next.js middleware is the cheapest place to shed abusive traffic because it runs before the route's own function boots, but it also sees RSC prefetch requests that carry the &lt;code&gt;RSC: 1&lt;/code&gt; header and that the user never intentionally made.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NextRequest.ip&lt;/code&gt; was removed in Next.js 15. On Vercel, read the client address with &lt;code&gt;ipAddress(request)&lt;/code&gt; from &lt;code&gt;@vercel/functions&lt;/code&gt; instead of trusting a raw &lt;code&gt;x-forwarded-for&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;Every Next.js Server Action POSTs to the current page URL and carries a build-generated &lt;code&gt;Next-Action&lt;/code&gt; header, so the only reliable place to limit a specific action is inside the action body.&lt;/li&gt;
&lt;li&gt;Reject with status 429 and a &lt;code&gt;Retry-After&lt;/code&gt; header, and decide fail-open versus fail-closed per route &lt;em&gt;before&lt;/em&gt; your Redis has its first outage.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does an in-memory rate limiter break on serverless?
&lt;/h2&gt;

&lt;p&gt;A module-scope &lt;code&gt;Map&lt;/code&gt; is private to one function instance, and a serverless platform runs many instances at once. Each instance therefore enforces the full limit on its own, so the effective limit is your configured limit multiplied by the number of warm instances. That number is not something you control or can observe from inside the request.&lt;/p&gt;

&lt;p&gt;This is the code I have shipped and regretted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Do not ship this to a serverless runtime.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&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;max&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="nx"&gt;windowMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hits&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;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reset&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;hits&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;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&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;reset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;windowMs&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;max&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;Vercel Fluid Compute makes this harder to notice rather than easier. Fluid Compute reuses a single function instance across concurrent requests instead of spawning one per request, so the &lt;code&gt;Map&lt;/code&gt; survives far longer than it did under classic serverless. In local development and in a quiet preview deployment the limiter looks correct. It only comes apart under traffic spread across enough instances to matter, and nothing in the logs announces it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Map&lt;/code&gt; also never shrinks. On a long-lived instance every unique key you have ever seen stays resident until the instance is recycled, which is a slow memory leak wearing a rate limiter costume.&lt;/p&gt;

&lt;p&gt;The fix is not a cleverer &lt;code&gt;Map&lt;/code&gt;. The counter has to live in a store that every instance shares and that supports an atomic increment: Redis, or any datastore with a compare-and-set primitive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should the limit run in middleware or in the route handler?
&lt;/h2&gt;

&lt;p&gt;Both, for different jobs. Middleware runs before Next.js resolves the route, so a request rejected there never boots the route's function and never touches your database. That makes middleware the correct place for a coarse, identity-agnostic abuse limit. The route handler knows the authenticated user, the parsed body, and the business meaning of the call, which makes it the correct place for a per-user quota.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// middleware.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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;ipAddress&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;@vercel/functions&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;ratelimit&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/ratelimit&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;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/:path*&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;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/signup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// RSC prefetches fire on link hover. The user did not ask for these.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rsc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ipAddress&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="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;127.0.0.1&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="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="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reset&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ratelimit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`ip:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ip&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="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rate_limited&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Too many requests.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&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="s1"&gt;Retry-After&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;reset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-RateLimit-Limit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-RateLimit-Remaining&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-RateLimit-Reset&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reset&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 &lt;code&gt;rsc&lt;/code&gt; header check is the line I added after watching my own quota drain while I did nothing but move a mouse. The Next.js router prefetches linked routes on hover and on viewport entry, and those requests are real HTTP requests that hit middleware with an &lt;code&gt;RSC: 1&lt;/code&gt; header. Counting them means a user who scrolls a navigation-heavy page is rate limited before they click anything.&lt;/p&gt;

&lt;p&gt;Since Next.js 15.5 you can also opt middleware into the Node.js runtime with &lt;code&gt;export const config = { runtime: 'nodejs' }&lt;/code&gt;, which lets you use a normal Redis client there. I still prefer an HTTP-based store in middleware, because middleware sits on the latency path of every matched request.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Placement&lt;/th&gt;
&lt;th&gt;Sees&lt;/th&gt;
&lt;th&gt;Cost of a rejection&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Middleware&lt;/td&gt;
&lt;td&gt;URL, headers, cookies, IP&lt;/td&gt;
&lt;td&gt;Lowest — route function never boots&lt;/td&gt;
&lt;td&gt;IP-level abuse and brute-force shielding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route handler&lt;/td&gt;
&lt;td&gt;Everything, including session and body&lt;/td&gt;
&lt;td&gt;Function has already started&lt;/td&gt;
&lt;td&gt;Per-user quotas, per-endpoint cost control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server Action body&lt;/td&gt;
&lt;td&gt;Session, typed arguments&lt;/td&gt;
&lt;td&gt;Function has already started&lt;/td&gt;
&lt;td&gt;Form submissions and mutations&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Which algorithm should I actually use?
&lt;/h2&gt;

&lt;p&gt;Pick the cheapest algorithm whose failure mode you can live with.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fixed window&lt;/strong&gt; keeps one counter per window, so a check is a single &lt;code&gt;INCR&lt;/code&gt;. Its flaw is the boundary: ten requests at 11:59:59 and ten more at 12:00:00 pass a "ten per minute" limit while delivering twenty requests in one second.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sliding window log&lt;/strong&gt; stores a timestamp per request and is exact, but its memory grows with your traffic, which is the wrong direction for a defence against traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sliding window counter&lt;/strong&gt; weights the previous window by how much of it still overlaps the current one. Bounded memory, no boundary burst, and my default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token bucket&lt;/strong&gt; gives each identity a capacity and a refill rate, so a quiet client may spend saved tokens at once. Right for APIs whose clients legitimately batch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;@upstash/ratelimit&lt;/code&gt; package ships all four as &lt;code&gt;fixedWindow&lt;/code&gt;, &lt;code&gt;slidingWindow&lt;/code&gt;, &lt;code&gt;tokenBucket&lt;/code&gt;, and &lt;code&gt;cachedFixedWindow&lt;/code&gt;. If you write your own against Redis, the thing to get right is atomicity. &lt;code&gt;INCR&lt;/code&gt; followed by a separate &lt;code&gt;EXPIRE&lt;/code&gt; is two round trips, and if the process dies between them you have created a key with no expiry, which locks that identity out permanently. Do it in one script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- One round trip. The TTL is set only on the first hit of a window.&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INCR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PEXPIRE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&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="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I identify the client when everything sits behind a proxy?
&lt;/h2&gt;

&lt;p&gt;A rate limit is only as good as its key. &lt;code&gt;NextRequest.ip&lt;/code&gt; and &lt;code&gt;NextRequest.geo&lt;/code&gt; were removed in Next.js 15, so reading &lt;code&gt;request.ip&lt;/code&gt; is now a type error rather than a subtle wrong answer. On Vercel the replacement is &lt;code&gt;ipAddress(request)&lt;/code&gt; from &lt;code&gt;@vercel/functions&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Do not key on a raw &lt;code&gt;x-forwarded-for&lt;/code&gt; header unless you are certain a trusted proxy overwrites it. &lt;code&gt;x-forwarded-for&lt;/code&gt; is an ordinary request header, so on any origin reachable directly, an attacker sets it to a new value per request and gets an unlimited number of fresh limit buckets. That is a complete bypass, not a partial failure.&lt;/p&gt;

&lt;p&gt;IPv6 needs its own rule. A single residential subscriber is routinely assigned an entire /64 prefix, so keying on the full 128-bit address hands one attacker 2^64 distinct identities.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;identityKey&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;userId&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// A stable account beats a network address whenever you have one.&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;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`user:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;ipAddress&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="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0.0.0.0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// One subscriber can own a whole /64. Key the prefix, not the address.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`ip6:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`ip4:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I rate limit a Server Action when every action is a POST to the same URL?
&lt;/h2&gt;

&lt;p&gt;A Server Action is a function marked with the &lt;code&gt;'use server'&lt;/code&gt; directive that the client invokes over the network. The invocation is an HTTP POST to the URL of the page the action was called from, with a &lt;code&gt;Next-Action&lt;/code&gt; header containing a build-generated identifier for that specific action. There is no dedicated route path, which is exactly what breaks the obvious approach.&lt;/p&gt;

&lt;p&gt;In middleware, a POST from a contact form on &lt;code&gt;/contact&lt;/code&gt; and a POST from a delete-account button on &lt;code&gt;/contact&lt;/code&gt; are the same URL and the same method. You can read &lt;code&gt;request.headers.get('next-action')&lt;/code&gt; to at least distinguish action POSTs from ordinary document requests, but that identifier is a hash that changes when the build changes, so branch on its presence and never on its value.&lt;/p&gt;

&lt;p&gt;The reliable place is inside the action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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;headers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/headers&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;auth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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;ratelimit&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/ratelimit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_prev&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;State&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;State&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;session&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;auth&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;session&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;id&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-forwarded-for&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anon&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="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="nx"&gt;reset&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ratelimit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`action:sendMessage:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&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;success&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;seconds&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;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;reset&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Return a value. A thrown error reaches production as an opaque digest.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Too many messages. Try again in &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s.`&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// ...the real work&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returning a value rather than throwing matters. An uncaught error inside a Server Action is redacted in production and surfaces to the client as a generic message with a digest, so the user is told something went wrong instead of being told to wait forty seconds. Rate limiting is a normal outcome, not an exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should a 429 response actually contain?
&lt;/h2&gt;

&lt;p&gt;HTTP 429 Too Many Requests is the correct status, and &lt;code&gt;Retry-After&lt;/code&gt; is the header that makes a limiter usable by anyone other than a human staring at a browser. Its value is either a number of seconds or an HTTP date.&lt;/p&gt;

&lt;p&gt;Alongside it, emit the limit state. The &lt;code&gt;X-RateLimit-Limit&lt;/code&gt; / &lt;code&gt;X-RateLimit-Remaining&lt;/code&gt; / &lt;code&gt;X-RateLimit-Reset&lt;/code&gt; convention is not a standard but it is what most SDKs already parse. The IETF draft &lt;code&gt;draft-ietf-httpapi-ratelimit-headers&lt;/code&gt; defines &lt;code&gt;RateLimit&lt;/code&gt; and &lt;code&gt;RateLimit-Policy&lt;/code&gt; as structured fields; adopt it only if your consumers understand it.&lt;/p&gt;

&lt;p&gt;Two mistakes I have made and seen: returning 403 for a rate limit, which tells the client to stop forever rather than to retry, and returning 500, which pollutes your error rate with your own defences working correctly.&lt;/p&gt;

&lt;p&gt;Then decide what happens when the store is unreachable, because it will be. Failing open on a login endpoint turns a Redis outage into an open brute-force window. Failing closed on a public read endpoint turns a Redis outage into a full outage of your site. I choose per route: fail closed on authentication and on anything that spends money, fail open on reads.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I rate limit in Next.js without Redis?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Only if your app runs as a single long-lived process, such as one container instance. On any serverless or autoscaled deployment the counter must live in a store shared across instances, because in-process state is multiplied by your instance count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does Next.js middleware run on RSC prefetch requests?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. Router prefetches are real HTTP requests that match your middleware &lt;code&gt;matcher&lt;/code&gt; and carry the &lt;code&gt;RSC: 1&lt;/code&gt; header. Exclude them from user-facing quotas or they will consume a visitor's budget before the visitor clicks anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I get the client IP in Next.js 15 and Next.js 16?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; &lt;code&gt;NextRequest.ip&lt;/code&gt; was removed in Next.js 15. On Vercel, call &lt;code&gt;ipAddress(request)&lt;/code&gt; from &lt;code&gt;@vercel/functions&lt;/code&gt;. Elsewhere, read the forwarded header your own trusted proxy sets and confirm that the proxy overwrites rather than appends it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Should a rate limiter fail open or fail closed when Redis is down?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Decide per route. Fail closed on login, signup, password reset, and payment endpoints, where failing open creates a security window. Fail open on public reads, where failing closed converts a dependency outage into a site outage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is Vercel BotID a replacement for rate limiting?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. Vercel BotID is bot detection, which answers whether a caller is automated. A rate limit answers how often any caller, human or not, may perform an expensive operation. They defend different things and compose well together.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/rate-limiting-nextjs-app-router-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/rate-limiting-nextjs-app-router-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>security</category>
      <category>redis</category>
    </item>
    <item>
      <title>File Uploads in the Next.js App Router: Field Notes on Vercel Blob, Client Uploads, and the Callback That Never Fires on localhost</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Tue, 18 Aug 2026 06:00:08 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/file-uploads-in-the-nextjs-app-router-field-notes-on-vercel-blob-client-uploads-and-the-4359</link>
      <guid>https://dev.to/ahmed_mahmoud360/file-uploads-in-the-nextjs-app-router-field-notes-on-vercel-blob-client-uploads-and-the-4359</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; A Vercel Blob client upload sends the file directly from the browser to blob storage and never routes the bytes through your Next.js function, which is why it handles multi-gigabyte files that a route handler cannot. The cost is that your database row is written by a server-to-server callback named &lt;code&gt;onUploadCompleted&lt;/code&gt;, and that callback never reaches &lt;code&gt;http://localhost:3000&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I moved a document-upload feature off a plain route handler and onto Vercel Blob client uploads this month. The client-side code took twenty minutes. The next two days went to a callback that worked in production and silently did nothing on my machine, to a 409 I did not expect, and to a small pile of blobs nobody had a database row for. These are the notes I wish I had started with.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Vercel Blob is Vercel's object storage service. The &lt;code&gt;@vercel/blob&lt;/code&gt; package exposes &lt;code&gt;put()&lt;/code&gt;, &lt;code&gt;head()&lt;/code&gt;, &lt;code&gt;list()&lt;/code&gt;, &lt;code&gt;copy()&lt;/code&gt; and &lt;code&gt;del()&lt;/code&gt; for server code, and a separate &lt;code&gt;@vercel/blob/client&lt;/code&gt; entry point for browser uploads.&lt;/li&gt;
&lt;li&gt;A client upload is a three-hop handshake: the browser asks your route handler for a one-time token, the browser sends the bytes directly to blob storage, then blob storage calls your route handler back at &lt;code&gt;onUploadCompleted&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;onUploadCompleted&lt;/code&gt; is an inbound HTTP request from Vercel's infrastructure to your public deployment URL, so it never arrives on localhost. Without a tunnel the upload succeeds and the database row is never written.&lt;/li&gt;
&lt;li&gt;Since &lt;code&gt;@vercel/blob&lt;/code&gt; v1 the &lt;code&gt;addRandomSuffix&lt;/code&gt; option defaults to &lt;code&gt;false&lt;/code&gt;, so uploading the same pathname twice fails with HTTP 409 unless you pass &lt;code&gt;allowOverwrite: true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;allowedContentTypes&lt;/code&gt; option validates the MIME type the browser declares, and the browser derives that from the file extension. Verify the file's magic bytes on the server before you trust it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why not just POST the file to a route handler?
&lt;/h2&gt;

&lt;p&gt;A route handler upload is the right choice for small files and the wrong choice for large ones, because every byte travels through a serverless function you are billed for and that has a wall-clock limit. Vercel Functions now accept request bodies up to 100 MB, up from the old 4.5 MB ceiling, so proxying moderately sized files is genuinely viable in 2026 — it just does not scale past that.&lt;/p&gt;

&lt;p&gt;If you do proxy, pass &lt;code&gt;request.body&lt;/code&gt; straight into &lt;code&gt;put()&lt;/code&gt;. Calling &lt;code&gt;await request.formData()&lt;/code&gt; buffers the entire file in the function's memory before you have written a single byte to storage, which is how a 90 MB PDF turns into an out-of-memory error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/upload-proxy/route.ts — fine for small files, wrong for large ones&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;put&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;@vercel/blob&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;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;filename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;filename&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;filename&lt;/span&gt; &lt;span class="o"&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;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bad request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Passing request.body straight through avoids buffering the file in memory.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&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;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filename&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;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;addRandomSuffix&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;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="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Route handler (server proxy)&lt;/th&gt;
&lt;th&gt;Client upload (&lt;code&gt;@vercel/blob/client&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Practical file size ceiling&lt;/td&gt;
&lt;td&gt;100 MB, the Vercel Functions request-body limit&lt;/td&gt;
&lt;td&gt;Multi-gigabyte; bounded by the blob store, not the function&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Function compute cost&lt;/td&gt;
&lt;td&gt;Billed for the full duration of the transfer&lt;/td&gt;
&lt;td&gt;Two short calls: issuing the token and handling the callback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authentication&lt;/td&gt;
&lt;td&gt;Simple — session cookies are on the request&lt;/td&gt;
&lt;td&gt;Only on hop 1, inside &lt;code&gt;onBeforeGenerateToken&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Progress reporting&lt;/td&gt;
&lt;td&gt;Requires your own streaming plumbing&lt;/td&gt;
&lt;td&gt;Built in via &lt;code&gt;onUploadProgress&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works end to end on localhost&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No — the completion callback needs a public URL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How does a Vercel Blob client upload actually work?
&lt;/h2&gt;

&lt;p&gt;A Vercel Blob client upload runs in three hops, and understanding which hop you are in explains almost every bug you will hit. Hop 1 is the browser POSTing to your route handler to request a scoped upload token. Hop 2 is the browser sending the file bytes directly to Vercel Blob. Hop 3 is Vercel Blob POSTing back to that same route handler to tell you the upload finished.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;upload&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;@vercel/blob/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&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;blob&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;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;access&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;handleUploadUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/upload&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;clientPayload&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;folder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invoices&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;multipart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;onUploadProgress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;percentage&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;setProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;percentage&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;blob&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="c1"&gt;// already served from the CDN&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server side is a single route handler wrapping &lt;code&gt;handleUpload&lt;/code&gt;, which dispatches hop 1 and hop 3 for you based on the request body it receives.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/upload/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;handleUpload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;HandleUploadBody&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;@vercel/blob/client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&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;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;HandleUploadBody&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&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;handleUpload&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;// Hop 1: the browser's cookies are present here. This is your only auth gate.&lt;/span&gt;
      &lt;span class="na"&gt;onBeforeGenerateToken&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;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientPayload&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;session&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;auth&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;session&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;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;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="s2"&gt;`u/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;session&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;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="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;Forbidden pathname&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;allowedContentTypes&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;image/png&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;image/jpeg&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;application/pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
          &lt;span class="na"&gt;maximumSizeInBytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;addRandomSuffix&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;tokenPayload&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;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;session&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientPayload&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="c1"&gt;// Hop 3: server-to-server. No cookies, no session — only tokenPayload.&lt;/span&gt;
      &lt;span class="na"&gt;onUploadCompleted&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;blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokenPayload&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokenPayload&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;blob&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="na"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="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="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single most important detail is that &lt;code&gt;onUploadCompleted&lt;/code&gt; has no session. It is called by Vercel's infrastructure, not by your user's browser, so there are no cookies and no headers you control. Anything hop 3 needs to know must be serialized into &lt;code&gt;tokenPayload&lt;/code&gt; during hop 1. I lost an hour calling &lt;code&gt;auth()&lt;/code&gt; inside &lt;code&gt;onUploadCompleted&lt;/code&gt; and getting &lt;code&gt;null&lt;/code&gt; every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why doesn't onUploadCompleted fire on localhost?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;onUploadCompleted&lt;/code&gt; never fires on localhost because it is an inbound HTTP request from Vercel Blob to your application's public URL, and &lt;code&gt;http://localhost:3000&lt;/code&gt; is not routable from the public internet. The failure mode is deceptive: &lt;code&gt;upload()&lt;/code&gt; resolves successfully, the file is genuinely in the blob store, the UI shows a green checkmark — and your database table stays empty forever.&lt;/p&gt;

&lt;p&gt;The fix is to run your dev server behind a public tunnel and load the app through the tunnel hostname, not through localhost. &lt;code&gt;handleUpload&lt;/code&gt; derives the callback URL from the incoming request, so merely exposing the port is not enough; the browser has to be on the tunnel origin when it starts the upload.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Terminal 1&lt;/span&gt;
npm run dev

&lt;span class="c"&gt;# Terminal 2 — then open the printed https URL, not localhost:3000&lt;/span&gt;
ngrok http 3000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not paper over this with a client-side confirmation call. Having the browser POST to a second endpoint after &lt;code&gt;upload()&lt;/code&gt; resolves looks like it works, but it is best-effort by construction: if the tab closes in that gap the row is lost, and you now have two code paths that can write the same row.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I stop two users from overwriting each other's files?
&lt;/h2&gt;

&lt;p&gt;In Vercel Blob the pathname is the object's identity, so two uploads with the same pathname collide. Since &lt;code&gt;@vercel/blob&lt;/code&gt; v1 the &lt;code&gt;addRandomSuffix&lt;/code&gt; option defaults to &lt;code&gt;false&lt;/code&gt;, which means a second upload of &lt;code&gt;invoice.pdf&lt;/code&gt; returns HTTP 409 instead of quietly replacing the first one. That default is the safe one, and it caught a bug for me on day one.&lt;/p&gt;

&lt;p&gt;You have two coherent strategies. Set &lt;code&gt;addRandomSuffix: true&lt;/code&gt; and let Vercel append a random token to every pathname, which makes collisions impossible but means re-uploading the same file creates a second object. Or build a deterministic namespaced pathname such as &lt;code&gt;u/{userId}/{documentId}/{filename}&lt;/code&gt; and pass &lt;code&gt;allowOverwrite: true&lt;/code&gt;, which makes re-upload idempotent.&lt;/p&gt;

&lt;p&gt;The trap in the second strategy is that the client chooses the pathname. Combining a user-supplied pathname with &lt;code&gt;allowOverwrite: true&lt;/code&gt; lets one account clobber another account's file. Validate the prefix inside &lt;code&gt;onBeforeGenerateToken&lt;/code&gt; against the authenticated session, as in the handler above, and reject anything outside the caller's namespace.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I validate file type when the browser can lie?
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;allowedContentTypes&lt;/code&gt; option checks the &lt;code&gt;Content-Type&lt;/code&gt; the browser declares, and on most platforms the browser derives that value from the file extension. Renaming &lt;code&gt;payload.exe&lt;/code&gt; to &lt;code&gt;avatar.png&lt;/code&gt; is enough to make Chrome report &lt;code&gt;image/png&lt;/code&gt;, so &lt;code&gt;allowedContentTypes&lt;/code&gt; is a usability guard, not a security control.&lt;/p&gt;

&lt;p&gt;Real validation happens in &lt;code&gt;onUploadCompleted&lt;/code&gt;, after the bytes exist. Fetch the first few bytes with a &lt;code&gt;Range&lt;/code&gt; header, compare against the format's magic number, and &lt;code&gt;del()&lt;/code&gt; the blob if it does not match.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;del&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;@vercel/blob&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;PNG_MAGIC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="mh"&gt;0x89&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x4e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mh"&gt;0x47&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nl"&gt;onUploadCompleted&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;blob&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;// Read only the header; never download a whole file to check four bytes.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&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="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="na"&gt;Range&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bytes=0-7&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;head&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&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;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&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;4&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PNG_MAGIC&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;del&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&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="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&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="nx"&gt;blob&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="na"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One related correction I had to make to my own mental model: a blob created with &lt;code&gt;access: 'public'&lt;/code&gt; is world-readable to anyone holding the URL, and an unguessable URL is obscurity rather than access control. Vercel Blob supports private storage, so anything that must stay restricted should be uploaded with private access and served through a route handler that checks the session.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens to the blob when the database write fails?
&lt;/h2&gt;

&lt;p&gt;Nothing happens to the blob — it stays in the store and you keep paying for it. The blob store and your database are two independent systems with no shared transaction, so a failure inside &lt;code&gt;onUploadCompleted&lt;/code&gt; leaves an object no row points to. Over months of retries and timeouts that set grows quietly.&lt;/p&gt;

&lt;p&gt;I settled on two-phase bookkeeping. During hop 1, insert a row with status &lt;code&gt;pending&lt;/code&gt; keyed by the pathname you are about to authorize. During hop 3, flip it to &lt;code&gt;ready&lt;/code&gt;. Then run a scheduled sweeper that pages through the store with &lt;code&gt;list()&lt;/code&gt; and deletes anything older than a day that no row claims.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;del&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;@vercel/blob&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;cutoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;do&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;page&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;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;known&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;knownPathnames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orphans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;known&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uploadedAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;cutoff&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;orphans&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;del&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orphans&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The age cutoff matters. Without it the sweeper will delete a blob whose &lt;code&gt;onUploadCompleted&lt;/code&gt; is still in flight, which is a far worse bug than the leak it was meant to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I show real upload progress for a very large file?
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;upload()&lt;/code&gt; and &lt;code&gt;put()&lt;/code&gt; functions both accept an &lt;code&gt;onUploadProgress&lt;/code&gt; callback that receives &lt;code&gt;{ loaded, total, percentage }&lt;/code&gt;, so a real progress bar needs no custom streaming code. Setting &lt;code&gt;multipart: true&lt;/code&gt; splits the file into chunks uploaded in parallel with per-chunk retry, which is what makes a large upload survive a flaky connection.&lt;/p&gt;

&lt;p&gt;Two behaviours surprised me. With multipart enabled, progress advances in visible steps as parts complete rather than moving smoothly, so a naive animated bar looks broken. And multipart adds request overhead per chunk, which is pure waste on a 2 MB avatar — I gate it on file size rather than enabling it globally. If you need to let users cancel, &lt;code&gt;upload()&lt;/code&gt; accepts an &lt;code&gt;abortSignal&lt;/code&gt;, and aborting mid-flight means &lt;code&gt;onUploadCompleted&lt;/code&gt; simply never runs, which your reconciliation job already handles.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need a route handler if I use client uploads?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes. &lt;code&gt;handleUpload&lt;/code&gt; lives in a route handler that the browser calls to obtain a one-time token and that Vercel Blob calls back when the upload finishes. Only the file bytes bypass it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is a public Vercel Blob URL secure because it is unguessable?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. A blob created with &lt;code&gt;access: 'public'&lt;/code&gt; is readable by anyone who has the URL and is cached by the CDN. Use Vercel Blob's private storage plus a session-checking download route for anything sensitive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; When should I turn on &lt;code&gt;multipart: true&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; When a single failed transfer would be expensive to retry — roughly files of 100 MB and up. Multipart adds one request per chunk, so it is not worth the overhead for small images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What happens if the user closes the tab mid-upload?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; The transfer dies and &lt;code&gt;onUploadCompleted&lt;/code&gt; never runs, so no database row is created. Your reconciliation sweep is what removes the partial artefacts, which is one more reason to build it early.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I rename or move a blob after it is uploaded?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Not in place. Use &lt;code&gt;copy()&lt;/code&gt; from &lt;code&gt;@vercel/blob&lt;/code&gt; to write the object to a new pathname and then &lt;code&gt;del()&lt;/code&gt; the original. Because pathname is the blob's identity, choose a naming scheme before you have a million objects.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/file-uploads-nextjs-vercel-blob-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/file-uploads-nextjs-vercel-blob-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>vercel</category>
    </item>
    <item>
      <title>Web Push in the Next.js App Router: Field Notes on Service Workers, VAPID, and the iOS Rule That Silently Blocks Everything</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Mon, 17 Aug 2026 06:00:11 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/web-push-in-the-nextjs-app-router-field-notes-on-service-workers-vapid-and-the-ios-rule-that-1oj2</link>
      <guid>https://dev.to/ahmed_mahmoud360/web-push-in-the-nextjs-app-router-field-notes-on-service-workers-vapid-and-the-ios-rule-that-1oj2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Web Push is a browser API that delivers a server-sent notification to a device while your site is closed, and in a Next.js App Router project it needs exactly three parts: a service worker served from the origin root, a VAPID key pair, and a Node.js-runtime route handler that sends. On iOS Safari it needs a fourth thing nobody documents loudly enough — the user must install the site to the Home Screen first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I wired Web Push into a Next.js 16 App Router project this month. The happy path took an afternoon. The rest of the week went to iOS, to subscriptions that had quietly died months earlier, and to a service worker the browser refused to replace. These are the notes I wish I had on day one.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Web Push needs a service worker at the origin root (&lt;code&gt;/sw.js&lt;/code&gt;), because a service worker's scope can never be broader than the path it is served from. In Next.js that means &lt;code&gt;public/sw.js&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;VAPID (Voluntary Application Server Identification) is a public/private key pair that authenticates your server to the browser's push service. Generate it once with &lt;code&gt;npx web-push generate-vapid-keys&lt;/code&gt; and never rotate it casually.&lt;/li&gt;
&lt;li&gt;iOS Safari 16.4 and later support Web Push, but only after the user adds the site to the Home Screen and the app runs in &lt;code&gt;display: standalone&lt;/code&gt; mode. In a normal iOS tab, &lt;code&gt;window.PushManager&lt;/code&gt; is undefined.&lt;/li&gt;
&lt;li&gt;A push service returning HTTP 404 or 410 means the subscription is permanently dead. Delete the row immediately.&lt;/li&gt;
&lt;li&gt;Send pushes from the Node.js runtime, not the Edge runtime: the &lt;code&gt;web-push&lt;/code&gt; package uses Node's &lt;code&gt;crypto&lt;/code&gt; module for aes128gcm payload encryption.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does Web Push actually require in a Next.js app?
&lt;/h2&gt;

&lt;p&gt;Web Push requires three moving parts and nothing else: a registered service worker, a &lt;code&gt;PushSubscription&lt;/code&gt; obtained from &lt;code&gt;registration.pushManager.subscribe()&lt;/code&gt;, and a server that signs its requests with VAPID keys. There is no vendor SDK in the critical path — Firebase Cloud Messaging is one browser's push endpoint, not a requirement of the protocol.&lt;/p&gt;

&lt;p&gt;The service worker file has to live in &lt;code&gt;public/sw.js&lt;/code&gt; so Next.js serves it at &lt;code&gt;/sw.js&lt;/code&gt;. A service worker's &lt;em&gt;scope&lt;/em&gt; — the set of pages it is allowed to control — defaults to the directory it was served from, so a worker served from &lt;code&gt;/_next/static/sw.js&lt;/code&gt; can only control pages under &lt;code&gt;/_next/static/&lt;/code&gt;, which is no pages at all. Files in &lt;code&gt;public/&lt;/code&gt; are copied verbatim and never bundled, so you cannot &lt;code&gt;import&lt;/code&gt; npm packages there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// components/EnablePush.tsx (client component)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;enablePush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vapidPublicKey&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;reg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/sw.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Must be called inside a user gesture — click handler, not useEffect.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;permission&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;Notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermission&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;permission&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&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="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;sub&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;reg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pushManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;userVisibleOnly&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;applicationServerKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vapidPublicKey&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/push/subscribe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&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="s1"&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="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&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="nx"&gt;sub&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;sub&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;Chrome and Firefox accept a base64url string for &lt;code&gt;applicationServerKey&lt;/code&gt;. Older Safari builds and some Android WebViews still want a &lt;code&gt;Uint8Array&lt;/code&gt;, which is why most production code keeps a small &lt;code&gt;urlBase64ToUint8Array()&lt;/code&gt; helper around. &lt;code&gt;userVisibleOnly: true&lt;/code&gt; is not optional anywhere — you are promising to display a notification for every push you receive, and Chrome will eventually revoke a subscription that repeatedly breaks that promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does my push prompt do nothing on iOS Safari?
&lt;/h2&gt;

&lt;p&gt;iOS Safari only exposes the Push API to sites installed on the Home Screen. In a normal Safari tab on iOS, &lt;code&gt;window.PushManager&lt;/code&gt; is undefined and the permission prompt never appears — no error, no rejected promise, nothing to debug. Web Push landed in iOS 16.4 in March 2023 with exactly this constraint, and it still holds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isIOS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/iPad|iPhone|iPod/&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="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userAgent&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;isStandalone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(display-mode: standalone)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;matches&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;canPrompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PushManager&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isIOS&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;isStandalone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When an iOS user is browsing in a tab, show install instructions instead of a button that does nothing. You also need a manifest declaring &lt;code&gt;"display": "standalone"&lt;/code&gt;, or the installed shortcut opens back into Safari chrome and never qualifies.&lt;/p&gt;

&lt;p&gt;Safari 18.4, shipped in March 2025, added Declarative Web Push: the server sends a JSON payload containing a &lt;code&gt;web_push&lt;/code&gt; key set to &lt;code&gt;8030&lt;/code&gt; plus a &lt;code&gt;notification&lt;/code&gt; object, and the browser renders the notification without running your service worker's &lt;code&gt;push&lt;/code&gt; handler at all. It is additive — the classic service worker path is still required for Chrome and Firefox.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I send a push from a Next.js route handler?
&lt;/h2&gt;

&lt;p&gt;Send pushes from a route handler on the Node.js runtime using the &lt;code&gt;web-push&lt;/code&gt; package, which handles VAPID signing and aes128gcm payload encryption. Do not put &lt;code&gt;export const runtime = 'edge'&lt;/code&gt; on this route — the encryption path depends on Node's &lt;code&gt;crypto&lt;/code&gt; module. On Vercel the Node.js runtime is the default and runs on Fluid Compute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/push/send/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;webpush&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;web-push&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;webpush&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setVapidDetails&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;mailto:alerts@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;VAPID_PUBLIC_KEY&lt;/span&gt;&lt;span class="o"&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;VAPID_PRIVATE_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&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;req&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pushSubscription&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allSettled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;subs&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;s&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;webpush&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;p256dh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;p256dh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="p"&gt;}&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="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 404 / 410 = gone forever. Prune now, not "later".&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;results&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;r&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejected&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="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;410&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&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="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pushSubscription&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="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;subs&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;endpoint&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="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="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;sent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fulfilled&lt;/span&gt;&lt;span class="dl"&gt;'&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the payload small. Push services guarantee only about 4 KB of encrypted payload, and encryption overhead eats into that budget. Send an identifier plus a short title, then fetch the full record on click. Payload contents also sit on a third-party push server until delivery, which is a second reason to keep them thin.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// public/sw.js&lt;/span&gt;
&lt;span class="nb"&gt;self&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;push&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;event&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;registration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;showNotification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Update&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// same tag replaces instead of stacking&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="nb"&gt;self&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;notificationclick&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;event&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchAll&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="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="s1"&gt;window&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;includeUncontrolled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;wins&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;open&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wins&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;w&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;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&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;origin&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;open&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;focus&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openWindow&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;href&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;h2&gt;
  
  
  When should I delete a push subscription?
&lt;/h2&gt;

&lt;p&gt;Delete a subscription the moment the push service answers 404 or 410 Gone — those two status codes mean the endpoint will never be valid again. Subscriptions die constantly and silently: users clear site data, reinstall the browser, or revoke permission, and nothing notifies your server.&lt;/p&gt;

&lt;p&gt;The other status codes need different handling. A 413 means your payload exceeded the size limit. A 429 means you are rate limited and should honour the &lt;code&gt;Retry-After&lt;/code&gt; header instead of looping. A 401 or 403 almost always means your VAPID keys do not match the ones used at subscribe time — which is why rotating VAPID keys forces a re-subscribe across your whole user base.&lt;/p&gt;

&lt;p&gt;Browsers can also rotate an endpoint themselves, firing a &lt;code&gt;pushsubscriptionchange&lt;/code&gt; event in the service worker. Chrome fires it reliably; support elsewhere is uneven. Treat it as a bonus path and let 410-pruning plus a re-subscribe on the next visit be your real recovery mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Web Push vs SSE vs WebSockets: which should I use?
&lt;/h2&gt;

&lt;p&gt;Web Push is the only one of the three that works when your site is closed. The deciding question is whether the user is present.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Transport&lt;/th&gt;
&lt;th&gt;Works with the tab closed&lt;/th&gt;
&lt;th&gt;Direction&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;Web Push&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Server to device&lt;/td&gt;
&lt;td&gt;Re-engagement, alerts the user must not miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server-Sent Events (SSE)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Server to open page&lt;/td&gt;
&lt;td&gt;AI token streaming, progress, live feeds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebSockets&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Bidirectional&lt;/td&gt;
&lt;td&gt;Collaborative editing, chat, presence&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They compose. In the project I shipped, SSE carries live updates while the tab is open, and a background job fires a Web Push only when the user has had no active session for a few minutes. That single rule stopped the same event arriving twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks in production that local dev never shows?
&lt;/h2&gt;

&lt;p&gt;The failure that cost me most was a stale service worker. A browser keeps the old &lt;code&gt;sw.js&lt;/code&gt; until the file's bytes change and the new worker finishes installing, so a fixed &lt;code&gt;push&lt;/code&gt; handler can sit unused on real devices for a day. Calling &lt;code&gt;self.skipWaiting()&lt;/code&gt; in &lt;code&gt;install&lt;/code&gt; and &lt;code&gt;clients.claim()&lt;/code&gt; in &lt;code&gt;activate&lt;/code&gt; shortens that window, and logging a version string from the worker tells you which build actually handled a push.&lt;/p&gt;

&lt;p&gt;The second was permission UX. &lt;code&gt;Notification.requestPermission()&lt;/code&gt; can only be called from a user gesture, and once a user chooses Block you cannot prompt again from JavaScript on that origin — ever. Prompting on page load burns the one chance you get.&lt;/p&gt;

&lt;p&gt;The third was fan-out. Every &lt;code&gt;sendNotification()&lt;/code&gt; call is a separate HTTPS round trip to a third-party service, so tens of thousands of them do not belong in a request handler. Move that to a queue or background job, batched with &lt;code&gt;Promise.allSettled&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fourth was localhost lying to me. Service workers and the Push API are permitted on &lt;code&gt;http://localhost&lt;/code&gt; as a secure-context exception, so everything works locally and then fails on a staging host served over plain HTTP. Test push on a real HTTPS origin, on a real phone.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I need Firebase Cloud Messaging to send Web Push?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. FCM is Chrome's push endpoint, but the Web Push protocol with VAPID lets your own server post directly to whatever endpoint the browser hands you. The &lt;code&gt;web-push&lt;/code&gt; npm package speaks that protocol to Chrome, Firefox, and Safari endpoints alike.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I send a silent push that does not show a notification?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Not on the open web. &lt;code&gt;userVisibleOnly: true&lt;/code&gt; is mandatory in Chrome, Firefox, and Safari, and repeatedly receiving a push without displaying a notification can get the subscription revoked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why did all my subscriptions stop working after a deploy?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Almost always rotated VAPID keys. The public key is baked into every existing &lt;code&gt;PushSubscription&lt;/code&gt;, so a new key pair makes stored subscriptions fail authentication and every user has to re-subscribe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I test Web Push without waiting for a real event?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Chrome DevTools has a Push field under Application → Service Workers that dispatches a payload straight into your worker's &lt;code&gt;push&lt;/code&gt; handler. That tests rendering but skips VAPID and encryption, so also call your send route against your own subscription.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does Web Push work in a Next.js app installed to the iOS Home Screen?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. iOS treats an installed PWA as eligible regardless of framework, as long as the manifest sets &lt;code&gt;"display": "standalone"&lt;/code&gt; and the permission request comes from a user gesture inside the installed app.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/web-push-notifications-nextjs-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/web-push-notifications-nextjs-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>pwa</category>
    </item>
    <item>
      <title>Content Security Policy in the Next.js App Router: Field Notes on Nonces, strict-dynamic, and the Middleware That Made Every Page Dynamic</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Fri, 14 Aug 2026 06:02:54 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/content-security-policy-in-the-nextjs-app-router-field-notes-on-nonces-strict-dynamic-and-the-16d5</link>
      <guid>https://dev.to/ahmed_mahmoud360/content-security-policy-in-the-nextjs-app-router-field-notes-on-nonces-strict-dynamic-and-the-16d5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; A Content Security Policy (CSP) is an HTTP response header that tells the browser which script, style, and connection sources a document is allowed to use, and in the Next.js App Router the only script policy that survives the framework's runtime chunk loading is a per-request nonce combined with &lt;code&gt;'strict-dynamic'&lt;/code&gt;. The cost I did not budget for: generating that nonce in &lt;code&gt;middleware.ts&lt;/code&gt; and reading it with &lt;code&gt;headers()&lt;/code&gt; opts every matched route out of static rendering.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A CSP nonce is a per-request random token&lt;/strong&gt; that appears both in the &lt;code&gt;script-src&lt;/code&gt; directive and as a &lt;code&gt;nonce&lt;/code&gt; attribute on every allowed &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;. Because it must never repeat, HTML carrying a nonce cannot be cached — which is exactly why Next.js drops the route to dynamic rendering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A host allowlist cannot secure a Next.js app.&lt;/strong&gt; The App Router emits an inline bootstrap payload (&lt;code&gt;self.__next_f.push(...)&lt;/code&gt;) and then creates further &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; elements at runtime, so &lt;code&gt;script-src 'self'&lt;/code&gt; both fails to allow the inline payload and fails to distinguish framework chunks from any other same-origin file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;'strict-dynamic'&lt;/code&gt; propagates trust&lt;/strong&gt; from a nonce-allowed script to any script element that script creates programmatically. It is what makes chunk loading work without enumerating chunk URLs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keeping &lt;code&gt;'unsafe-inline'&lt;/code&gt; and &lt;code&gt;https:&lt;/code&gt; in &lt;code&gt;script-src&lt;/code&gt; is a deliberate fallback, not a hole.&lt;/strong&gt; A browser that understands nonces ignores &lt;code&gt;'unsafe-inline'&lt;/code&gt;, and a browser that understands &lt;code&gt;'strict-dynamic'&lt;/code&gt; ignores every host expression in the same directive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship &lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt; first.&lt;/strong&gt; The enforcing header turns a policy mistake into a blank page; the report-only header turns the same mistake into a log line.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does a Content Security Policy actually stop?
&lt;/h2&gt;

&lt;p&gt;A Content Security Policy does not stop injection. It stops execution. If an attacker gets &lt;code&gt;&amp;lt;script&amp;gt;fetch('/api/me')&amp;lt;/script&amp;gt;&lt;/code&gt; into a comment field that I render with &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt;, the markup still lands in the DOM — but a policy without &lt;code&gt;'unsafe-inline'&lt;/code&gt; means the browser refuses to run it and emits a violation report instead.&lt;/p&gt;

&lt;p&gt;Four directives paid for themselves in my apps before I touched &lt;code&gt;script-src&lt;/code&gt; at all, because none of them require a nonce and none of them break anything: &lt;code&gt;object-src 'none'&lt;/code&gt; removes the legacy plugin vector, &lt;code&gt;base-uri 'self'&lt;/code&gt; stops an injected &lt;code&gt;&amp;lt;base&amp;gt;&lt;/code&gt; tag from silently repointing every relative URL on the page, &lt;code&gt;form-action 'self'&lt;/code&gt; stops an injected form from posting credentials to another origin, and &lt;code&gt;frame-ancestors 'none'&lt;/code&gt; is the modern replacement for &lt;code&gt;X-Frame-Options&lt;/code&gt;. Those four can go in &lt;code&gt;next.config.js&lt;/code&gt; under &lt;code&gt;headers()&lt;/code&gt; and stay fully cacheable.&lt;/p&gt;

&lt;p&gt;The expensive directive is &lt;code&gt;script-src&lt;/code&gt;. That is the one that requires the nonce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does Next.js need a nonce instead of a domain allowlist?
&lt;/h2&gt;

&lt;p&gt;A domain allowlist cannot express what the App Router does at runtime. Next.js serializes the React Server Component payload into inline &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags on the document, and its client runtime then creates additional script elements to fetch route chunks on demand. &lt;code&gt;script-src 'self'&lt;/code&gt; blocks the inline payload outright, and even if it did not, &lt;code&gt;'self'&lt;/code&gt; would happily execute any same-origin URL — including a user-uploaded file served from my own domain.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;'strict-dynamic'&lt;/code&gt; is the CSP Level 3 keyword that fixes this. It says: any script element created by an already-trusted script inherits that trust. The inline bootstrap gets its trust from the nonce, and every chunk it loads afterwards inherits it. No chunk hashes, no build-time URL enumeration.&lt;/p&gt;

&lt;p&gt;The counterintuitive part is what &lt;code&gt;'strict-dynamic'&lt;/code&gt; switches off. In a browser that implements it, all host-source expressions in the same directive — &lt;code&gt;'self'&lt;/code&gt;, &lt;code&gt;https:&lt;/code&gt;, a literal CDN domain — are ignored. That is why the recommended policy still lists them: they are a graceful degradation path for older browsers, not additional permission for modern ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I generate and propagate a CSP nonce in the App Router?
&lt;/h2&gt;

&lt;p&gt;The nonce is generated once per request in &lt;code&gt;middleware.ts&lt;/code&gt;, written to both the request headers and the response headers, and read back in a Server Component with &lt;code&gt;headers()&lt;/code&gt;. Writing it onto the &lt;em&gt;request&lt;/em&gt; matters: Next.js looks for a &lt;code&gt;content-security-policy&lt;/code&gt; request header, extracts the nonce from it, and applies that nonce to the script tags it emits itself. Skip that step and the framework's own bootstrap is blocked by my own policy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// middleware.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;NextRequest&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;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&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;dev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&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;csp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;`default-src 'self'`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`script-src 'self' 'nonce-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;' 'strict-dynamic' https: 'unsafe-inline'&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dev&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; 'unsafe-eval'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`style-src 'self' 'unsafe-inline'`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`img-src 'self' blob: data:`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`connect-src 'self'`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`object-src 'none'`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`base-uri 'self'`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`form-action 'self'`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`frame-ancestors 'none'`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`upgrade-insecure-requests`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestHeaders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;requestHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-nonce&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;requestHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-security-policy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;csp&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="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requestHeaders&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-security-policy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;csp&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading it in the root layout is two lines. &lt;code&gt;headers()&lt;/code&gt; returns a promise in Next.js 15 and later, so it must be awaited, and &lt;code&gt;next/script&lt;/code&gt; forwards a &lt;code&gt;nonce&lt;/code&gt; prop straight onto the emitted tag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/headers&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;Script&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/script&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootLayout&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nonce&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-nonce&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="kc"&gt;undefined&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;html&lt;/span&gt; &lt;span class="na"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"en"&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;body&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Script&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://plausible.io/js/script.js"&lt;/span&gt; &lt;span class="na"&gt;nonce&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;nonce&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"afterInteractive"&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;body&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;html&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;h2&gt;
  
  
  Why did my static pages turn dynamic after I added CSP?
&lt;/h2&gt;

&lt;p&gt;Because a nonce is only a security control while it is unpredictable, and a cached HTML response hands the same nonce to every visitor. A reused nonce is functionally identical to &lt;code&gt;'unsafe-inline'&lt;/code&gt;: an attacker who can read one page's markup learns the token that unlocks script execution on every other copy of it. Next.js enforces the safe interpretation by treating &lt;code&gt;headers()&lt;/code&gt; as a dynamic API — the moment my root layout calls it, every route under that layout renders per request.&lt;/p&gt;

&lt;p&gt;That is a real bill. Marketing pages that were prerendered at build time became server-rendered on every hit, and Partial Prerendering could no longer treat the shell as static.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;Script safety&lt;/th&gt;
&lt;th&gt;Rendering cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Nonce + &lt;code&gt;'strict-dynamic'&lt;/code&gt; everywhere&lt;/td&gt;
&lt;td&gt;Strongest — no inline injection executes&lt;/td&gt;
&lt;td&gt;Every matched route renders dynamically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nonce scoped to authenticated routes, static policy elsewhere&lt;/td&gt;
&lt;td&gt;Strong where user input is rendered&lt;/td&gt;
&lt;td&gt;Marketing and docs pages stay static&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No nonce, &lt;code&gt;script-src 'self' 'unsafe-inline'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Weak — injected inline script still runs&lt;/td&gt;
&lt;td&gt;Fully static&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I landed on the middle row for content-heavy sites and the top row for anything behind a login. Scoping is done through the middleware &lt;code&gt;matcher&lt;/code&gt;, and Next.js's documented example additionally excludes prefetch requests so a prefetched RSC payload does not burn a nonce it will never use.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/((?!api|_next/static|_next/image|favicon.ico).*)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;header&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next-router-prefetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;header&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;purpose&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prefetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What breaks in development and with CSS-in-JS?
&lt;/h2&gt;

&lt;p&gt;Development needs &lt;code&gt;'unsafe-eval'&lt;/code&gt; in &lt;code&gt;script-src&lt;/code&gt;. React Fast Refresh and eval-based source maps both compile strings at runtime, so a production-grade policy gives me a console full of &lt;code&gt;EvalError&lt;/code&gt; the moment I run the dev server. Gate it on &lt;code&gt;process.env.NODE_ENV&lt;/code&gt; rather than shipping it everywhere.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;style-src&lt;/code&gt; is where I stopped fighting. Next.js inlines critical CSS as &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; elements, and CSS-in-JS libraries inject more at runtime, frequently from code paths that never see my nonce. I keep &lt;code&gt;'unsafe-inline'&lt;/code&gt; in &lt;code&gt;style-src&lt;/code&gt; deliberately: style injection is a far weaker vector than script injection, and the alternative is a policy that breaks on every dependency upgrade. If a threat model demands a style nonce, styled-components reads it from the &lt;code&gt;__webpack_nonce__&lt;/code&gt; global and Emotion accepts one via &lt;code&gt;createCache({ nonce })&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Three more that caught me: &lt;code&gt;next/image&lt;/code&gt; blur placeholders are &lt;code&gt;data:&lt;/code&gt; URLs, so &lt;code&gt;img-src&lt;/code&gt; needs &lt;code&gt;data:&lt;/code&gt; and usually &lt;code&gt;blob:&lt;/code&gt;; analytics beacons need their host in &lt;code&gt;connect-src&lt;/code&gt;, not &lt;code&gt;script-src&lt;/code&gt;, because the script is loaded but the beacon is a separate fetch; and embedded Stripe or YouTube iframes need &lt;code&gt;frame-src&lt;/code&gt;, which does not inherit from &lt;code&gt;default-src&lt;/code&gt; once you start listing directives explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should I roll out CSP without breaking production?
&lt;/h2&gt;

&lt;p&gt;Send the identical policy under &lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt; first and leave it there for a full traffic cycle — at minimum a week, so weekday and weekend behaviour both show up. The report-only header instructs the browser to evaluate the policy and report violations without blocking anything, which converts an outage into a log stream.&lt;/p&gt;

&lt;p&gt;Collecting the reports takes one Route Handler. The legacy &lt;code&gt;report-uri&lt;/code&gt; directive posts a single JSON object with content type &lt;code&gt;application/csp-report&lt;/code&gt;; the newer Reporting API uses a &lt;code&gt;Reporting-Endpoints&lt;/code&gt; response header plus a &lt;code&gt;report-to&lt;/code&gt; directive and posts batched arrays as &lt;code&gt;application/reports+json&lt;/code&gt;. Browsers are split across both, so I send both directives and normalise on arrival.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/csp-report/route.ts&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;reports&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="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="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;report&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;reports&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;blocked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;blockedURL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;csp-report&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;blocked-uri&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blocked&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;chrome-extension:&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;blocked&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;moz-extension:&lt;/span&gt;&lt;span class="dl"&gt;'&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;console&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="s1"&gt;[csp]&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;204&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;Filter browser extensions before you alert on anything. The first day I collected reports, the overwhelming majority came from &lt;code&gt;chrome-extension:&lt;/code&gt; and &lt;code&gt;moz-extension:&lt;/code&gt; origins injecting scripts into pages — noise I cannot fix and should not page anyone about. What is left after that filter is the actual list of things my policy would have broken.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does a CSP nonce protect me if I render untrusted HTML with &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt;?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; For script execution, yes. An injected &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; without the matching nonce will not run, and inline event handler attributes such as &lt;code&gt;onerror&lt;/code&gt; are blocked by the same absence of &lt;code&gt;'unsafe-inline'&lt;/code&gt;. It does not stop the markup itself from rendering, so a CSP is not a substitute for sanitising with a library like DOMPurify.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I use a nonce-based CSP with a statically exported Next.js site?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. A nonce must be generated per request, and a static export has no request-time compute. The options are a hash-based &lt;code&gt;script-src&lt;/code&gt; — which requires extracting the hashes of the inline bootstrap after every build, since they change per build — or serving the export behind an edge function that injects the header.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does adding &lt;code&gt;'strict-dynamic'&lt;/code&gt; make my policy weaker?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; It makes it narrower. &lt;code&gt;'strict-dynamic'&lt;/code&gt; causes conforming browsers to ignore every host allowlist entry in &lt;code&gt;script-src&lt;/code&gt;, so a CDN domain that was previously trusted wholesale is no longer trusted at all. Trust flows only from the nonce outward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need the &lt;code&gt;X-Frame-Options&lt;/code&gt; header?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; &lt;code&gt;frame-ancestors&lt;/code&gt; supersedes it in every current browser, and where both are present &lt;code&gt;frame-ancestors&lt;/code&gt; wins. Keeping &lt;code&gt;X-Frame-Options: DENY&lt;/code&gt; alongside it costs nothing and covers very old clients.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Should the CSP live in &lt;code&gt;next.config.js&lt;/code&gt; or in middleware?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Put every static directive — &lt;code&gt;frame-ancestors&lt;/code&gt;, &lt;code&gt;object-src&lt;/code&gt;, &lt;code&gt;base-uri&lt;/code&gt;, &lt;code&gt;form-action&lt;/code&gt; — in the &lt;code&gt;headers()&lt;/code&gt; function in &lt;code&gt;next.config.js&lt;/code&gt;, because those responses stay cacheable. Only a policy containing a per-request nonce needs middleware, and only on the routes that require it.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/csp-nonce-nextjs-app-router-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/csp-nonce-nextjs-app-router-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>security</category>
      <category>webdev</category>
      <category>react</category>
    </item>
    <item>
      <title>Passkeys in Production: Field Notes on WebAuthn, Conditional UI, and the RP ID That Broke My Login</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Thu, 13 Aug 2026 06:01:58 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/passkeys-in-production-field-notes-on-webauthn-conditional-ui-and-the-rp-id-that-broke-my-login-5aop</link>
      <guid>https://dev.to/ahmed_mahmoud360/passkeys-in-production-field-notes-on-webauthn-conditional-ui-and-the-rp-id-that-broke-my-login-5aop</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; A passkey is a WebAuthn public-key credential held by the device's authenticator and permanently bound to one Relying Party ID (RP ID), and it replaces the password and the second factor in a single prompt. Three details caused nearly every bug I hit: the RP ID must be the origin's registrable domain or a parent of it, &lt;code&gt;user.id&lt;/code&gt; must be an opaque handle rather than an email address, and the browser's autofill passkey chip only appears when you call &lt;code&gt;navigator.credentials.get()&lt;/code&gt; with &lt;code&gt;mediation: 'conditional'&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RP ID is permanent and one-directional.&lt;/strong&gt; A passkey registered with &lt;code&gt;rp.id: 'example.com'&lt;/code&gt; is usable from &lt;code&gt;app.example.com&lt;/code&gt;. A passkey registered with &lt;code&gt;rp.id: 'app.example.com'&lt;/code&gt; is never usable from &lt;code&gt;example.com&lt;/code&gt;. Pick the apex domain before your first user registers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;user.id&lt;/code&gt; is an opaque account handle, not an identifier.&lt;/strong&gt; The WebAuthn spec caps it at 64 bytes and states it must not contain personally identifying information. An email address there is baked into the credential and cannot be rotated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Usernameless sign-in requires a discoverable credential&lt;/strong&gt;, requested with &lt;code&gt;authenticatorSelection.residentKey: 'required'&lt;/code&gt;. Without it, authentication must send an &lt;code&gt;allowCredentials&lt;/code&gt; list, which means you need the username first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conditional UI is a separate call.&lt;/strong&gt; The passkey entry inside the browser's autofill dropdown needs &lt;code&gt;autocomplete="username webauthn"&lt;/code&gt; on the input plus &lt;code&gt;mediation: 'conditional'&lt;/code&gt;; a plain &lt;code&gt;get()&lt;/code&gt; opens a blocking modal instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not delete the password on day one.&lt;/strong&gt; Passkeys move the account-recovery problem, they do not remove it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is a passkey, and what does it actually replace?
&lt;/h2&gt;

&lt;p&gt;A passkey is a WebAuthn credential whose private key never leaves the authenticator — a platform keychain such as iCloud Keychain, Google Password Manager, or Windows Hello, or a hardware security key — while the matching public key is stored on your server. Authentication is a signature: the server issues a random challenge, the authenticator signs it after a local user-verification gesture, and the server verifies the signature against the stored public key.&lt;/p&gt;

&lt;p&gt;Because no shared secret ever crosses the network, there is nothing for a phishing site to capture. The stronger property is enforced by the browser, not by the user: the browser will only surface a credential whose RP ID matches the current origin's registrable domain, so a lookalike domain cannot get the authenticator to sign at all. That is the part TOTP never solved — a one-time code can be relayed to an attacker in real time, and a passkey signature cannot.&lt;/p&gt;

&lt;p&gt;What a passkey replaces is the password plus the second factor, collapsed into one prompt. What it does not replace: session management, authorization, rate limiting, and account recovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I register a passkey from a Next.js App Router route handler?
&lt;/h2&gt;

&lt;p&gt;Registration is two route handlers: one that generates options and stores the challenge server-side, and one that verifies the attestation and persists the credential. I use &lt;code&gt;@simplewebauthn/server&lt;/code&gt; v13 rather than hand-rolling CBOR parsing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/passkey/register/options/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generateRegistrationOptions&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;@simplewebauthn/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="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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;requireSession&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;options&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;generateRegistrationOptions&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;rpName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Example&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;rpID&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;RP_ID&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// 'example.com' — the apex, never the subdomain&lt;/span&gt;
    &lt;span class="na"&gt;userID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// Uint8Array, &amp;lt;= 64 bytes, opaque, not the email&lt;/span&gt;
    &lt;span class="na"&gt;userName&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;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// what the OS picker shows&lt;/span&gt;
    &lt;span class="na"&gt;userDisplayName&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;attestationType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;excludeCredentials&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;credentials&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;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;transports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transports&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="na"&gt;authenticatorSelection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;residentKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;userVerification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;preferred&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveChallenge&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;id&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;challenge&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// single use, short TTL&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="nx"&gt;options&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;excludeCredentials&lt;/code&gt; is the field people skip, and skipping it lets the same authenticator enrol twice — the user then sees two identical entries in their picker and cannot tell them apart. &lt;code&gt;attestationType: 'none'&lt;/code&gt; is the right default for a consumer app: requesting attestation gives you an authenticator provenance statement you almost certainly have no policy for.&lt;/p&gt;

&lt;p&gt;I use &lt;code&gt;userVerification: 'preferred'&lt;/code&gt; rather than &lt;code&gt;'required'&lt;/code&gt;. The &lt;code&gt;'required'&lt;/code&gt; value rejects authenticators that cannot perform a local biometric or PIN check, which quietly excludes some security keys and older Android configurations. &lt;code&gt;'preferred'&lt;/code&gt; still reports whether verification happened via the &lt;code&gt;userVerified&lt;/code&gt; flag, so you can gate sensitive actions on it instead of gating registration.&lt;/p&gt;

&lt;p&gt;The browser half is four lines. Note the v13 signature — &lt;code&gt;startRegistration&lt;/code&gt; takes an options object, not a positional argument:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;startRegistration&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;@simplewebauthn/browser&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;optionsJSON&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/passkey/register/options&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;attestation&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;startRegistration&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;optionsJSON&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/passkey/register/verify&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;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="nx"&gt;attestation&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 use route handlers rather than Server Actions here. &lt;code&gt;navigator.credentials&lt;/code&gt; only runs in the browser inside a user gesture and in a secure context, so the flow is client-driven either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does my passkey work on one subdomain and not another?
&lt;/h2&gt;

&lt;p&gt;Because the browser matches the RP ID against the origin's registrable domain, and the match is one-directional: the RP ID may be the origin's domain or any parent domain of it, never a child. An origin of &lt;code&gt;https://app.example.com&lt;/code&gt; may claim an &lt;code&gt;rp.id&lt;/code&gt; of &lt;code&gt;app.example.com&lt;/code&gt; or &lt;code&gt;example.com&lt;/code&gt;. An origin of &lt;code&gt;https://example.com&lt;/code&gt; may not claim &lt;code&gt;app.example.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the mistake that is expensive to fix, because RP ID is written into the credential at registration and cannot be migrated. If you register users on &lt;code&gt;app.example.com&lt;/code&gt; and later add a second product on &lt;code&gt;dash.example.com&lt;/code&gt;, every existing passkey is stranded on the first subdomain.&lt;/p&gt;

&lt;p&gt;For genuinely different sites — a per-country domain, or a separate brand — the mechanism is Related Origin Requests. The RP serves a JSON document at &lt;code&gt;https://&amp;lt;rpID&amp;gt;/.well-known/webauthn&lt;/code&gt; with &lt;code&gt;Content-Type: application/json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"origins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"https://example.co.uk"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"https://example.de"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"https://example.app"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Related Origin Requests shipped in Chrome 128 and Safari 18. Browsers cap how many distinct registrable-domain labels they will process from that list — Chrome stops at five — so it is a mechanism for a handful of sibling domains, not a wildcard.&lt;/p&gt;

&lt;p&gt;One local-development note: &lt;code&gt;localhost&lt;/code&gt; is a secure context and works over plain HTTP, but any other dev hostname must be served over HTTPS, and the RP ID has to equal that hostname. A passkey registered against a local HTTPS dev hostname will never authenticate against production.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I make the passkey autofill prompt appear?
&lt;/h2&gt;

&lt;p&gt;Conditional mediation is what puts a passkey inside the browser's autofill dropdown instead of a modal dialog. It needs three things together: a discoverable credential, an input marked &lt;code&gt;autocomplete="username webauthn"&lt;/code&gt;, and a &lt;code&gt;get()&lt;/code&gt; call with &lt;code&gt;mediation: 'conditional'&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;startAuthentication&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;@simplewebauthn/browser&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="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;controller&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;AbortController&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="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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PublicKeyCredential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isConditionalMediationAvailable&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;optionsJSON&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/passkey/auth/options&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assertion&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;startAuthentication&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;optionsJSON&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;useBrowserAutofill&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;submitAssertion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;assertion&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AbortError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;})();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&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 traps live in that snippet. First, a conditional &lt;code&gt;get()&lt;/code&gt; returns a promise that stays pending indefinitely — it resolves only when the user picks a passkey from the dropdown. Start it once when the sign-in form mounts and abort it on unmount; firing a second &lt;code&gt;get()&lt;/code&gt; while one is outstanding throws instead of replacing it.&lt;/p&gt;

&lt;p&gt;Second, conditional mediation only offers discoverable credentials and requires an empty &lt;code&gt;allowCredentials&lt;/code&gt;. If your authentication options endpoint helpfully fills &lt;code&gt;allowCredentials&lt;/code&gt; from a known username, the autofill chip silently never appears and you will blame the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  What must the server verify on every assertion?
&lt;/h2&gt;

&lt;p&gt;The server must verify the challenge, the origin, the RP ID hash, the user-presence flag, and the signature — a client that reports success proves nothing, since the entire assertion arrives as attacker-controllable JSON.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;verifyAuthenticationResponse&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;@simplewebauthn/server&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;verification&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;verifyAuthenticationResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;assertion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;expectedChallenge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;storedChallenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;expectedOrigin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;expectedRPID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;transports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transports&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;requireUserVerification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;deleteChallenge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storedChallenge&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// delete on failure too&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;verification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verified&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;updateCounter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stored&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;verification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;authenticationInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;newCounter&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;Delete the challenge whether verification succeeded or failed. A challenge that survives a failed attempt is a replay window, and it is the single most common flaw I find when reviewing a hand-written WebAuthn implementation.&lt;/p&gt;

&lt;p&gt;The signature counter deserves a warning. It exists for clone detection, but most synced platform authenticators always report &lt;code&gt;0&lt;/code&gt;, so a naive rule of "reject unless the new counter is greater than the stored one" locks out every Apple and Google passkey user. Enforce monotonic increase only when the stored counter and the new counter are both non-zero.&lt;/p&gt;

&lt;p&gt;Persist &lt;code&gt;credentialBackedUp&lt;/code&gt; and &lt;code&gt;credentialDeviceType&lt;/code&gt; from the registration result. Those flags tell you whether a credential is synced across a user's devices or bound to one piece of hardware.&lt;/p&gt;

&lt;h2&gt;
  
  
  Passkeys vs password and TOTP vs magic links — which do I ship?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;Passkey (WebAuthn)&lt;/th&gt;
&lt;th&gt;Password + TOTP&lt;/th&gt;
&lt;th&gt;Magic link&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Phishing resistance&lt;/td&gt;
&lt;td&gt;Enforced by the browser via RP ID matching&lt;/td&gt;
&lt;td&gt;None — codes can be relayed in real time&lt;/td&gt;
&lt;td&gt;None — links are forwardable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server stores a secret&lt;/td&gt;
&lt;td&gt;No, only a public key&lt;/td&gt;
&lt;td&gt;Yes, hash plus TOTP seed&lt;/td&gt;
&lt;td&gt;No, but a live token sits in the mailbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sign-in steps&lt;/td&gt;
&lt;td&gt;One gesture&lt;/td&gt;
&lt;td&gt;Two fields plus an app switch&lt;/td&gt;
&lt;td&gt;App switch to email, then back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works on a borrowed device&lt;/td&gt;
&lt;td&gt;Yes, via cross-device hybrid transport&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main failure mode&lt;/td&gt;
&lt;td&gt;Recovery when every synced device is lost&lt;/td&gt;
&lt;td&gt;Real-time phishing and seed loss&lt;/td&gt;
&lt;td&gt;Email deliverability and mailbox compromise&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My default is passkey-first with a password kept as recovery, and TOTP retained only for accounts that already had it. Magic links stay in the stack for onboarding, not for repeat sign-in, because they make routine login depend on email latency.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks after a user deletes a passkey?
&lt;/h2&gt;

&lt;p&gt;Nothing breaks on the server, and everything breaks in the operating system's picker: the platform passkey manager keeps offering a credential you deleted server-side, the user selects it, and they get an error they have no way to interpret. The WebAuthn Signal API exists to close that gap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PublicKeyCredential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signalAllAcceptedCredentials&lt;/span&gt;&lt;span class="p"&gt;?.({&lt;/span&gt;
  &lt;span class="na"&gt;rpId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;base64urlUserHandle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;allAcceptedCredentialIds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;remaining&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;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are three methods and they map to three events. &lt;code&gt;signalAllAcceptedCredentials()&lt;/code&gt; runs after a user deletes a credential or right after a successful sign-in, and prunes stale entries. &lt;code&gt;signalUnknownCredential()&lt;/code&gt; runs when an assertion arrives for a credential ID you have no record of, and removes that single orphan. &lt;code&gt;signalCurrentUserDetails()&lt;/code&gt; runs after a user changes their name or email. The Signal API landed in Chromium 132, so optional-chain every call and treat it as progressive enhancement.&lt;/p&gt;

&lt;p&gt;One server-side rule is worth enforcing regardless of browser support: never let a user delete their last credential unless another way in exists.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I set the RP ID to a subdomain and change it later?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. RP ID is written into the credential at registration and cannot be migrated. A passkey registered with &lt;code&gt;rp.id: 'app.example.com'&lt;/code&gt; will never work on &lt;code&gt;example.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why doesn't the passkey appear in my browser's autofill dropdown?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Three causes, in order of likelihood: the input is missing &lt;code&gt;autocomplete="username webauthn"&lt;/code&gt;, the &lt;code&gt;get()&lt;/code&gt; call is missing &lt;code&gt;mediation: 'conditional'&lt;/code&gt;, or the authentication options include a non-empty &lt;code&gt;allowCredentials&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What should I put in &lt;code&gt;user.id&lt;/code&gt;?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; A random opaque handle of at most 64 bytes — a UUID or 16 random bytes stored alongside the account row. The WebAuthn spec states &lt;code&gt;user.id&lt;/code&gt; must not contain personally identifying information.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need passwords after shipping passkeys?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Keep one recovery path until passkey recovery is genuinely solved for your users. A passkey synced to a single vendor's cloud is lost when the user loses access to that account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Should I reject a sign-in when the signature counter did not increase?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Only when both the stored counter and the new counter are non-zero. Most synced platform authenticators always report &lt;code&gt;0&lt;/code&gt;, so a strict monotonic check locks out the majority of real passkey users.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/passkeys-webauthn-nextjs-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/passkeys-webauthn-nextjs-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>nextjs</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Modern CSS Deleted My Layout JavaScript: Field Notes on Container Queries, :has(), and Subgrid</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Tue, 11 Aug 2026 06:01:52 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/modern-css-deleted-my-layout-javascript-field-notes-on-container-queries-has-and-subgrid-4o6j</link>
      <guid>https://dev.to/ahmed_mahmoud360/modern-css-deleted-my-layout-javascript-field-notes-on-container-queries-has-and-subgrid-4o6j</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Four CSS features — container queries, &lt;code&gt;:has()&lt;/code&gt;, &lt;code&gt;subgrid&lt;/code&gt;, and cascade layers — let me delete most of the JavaScript I used to write for layout and conditional styling. Container queries size a component against its container instead of the viewport, &lt;code&gt;:has()&lt;/code&gt; styles a parent based on its children, &lt;code&gt;subgrid&lt;/code&gt; makes a nested grid adopt its parent's track lines, and &lt;code&gt;@layer&lt;/code&gt; makes override order explicit instead of a specificity race.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;container-type: inline-size&lt;/code&gt; on a wrapper plus &lt;code&gt;@container (min-width: 30rem)&lt;/code&gt; styles a component by the width of its container, so one card works in a sidebar and in a full-width grid with no viewport media query.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;:has()&lt;/code&gt; is the CSS parent selector: &lt;code&gt;.card:has(&amp;gt; img)&lt;/code&gt; matches a card that contains an image, and it re-evaluates live as the DOM and form state change.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grid-template-rows: subgrid&lt;/code&gt; makes a nested grid adopt its parent's row lines, which is how sibling cards align their titles and footers without fixed heights.&lt;/li&gt;
&lt;li&gt;Cascade layers — &lt;code&gt;@layer reset, base, components, utilities&lt;/code&gt; — decide override order by layer rather than selector specificity, so a one-class utility in a later layer beats a three-class component selector.&lt;/li&gt;
&lt;li&gt;Container queries, &lt;code&gt;:has()&lt;/code&gt;, &lt;code&gt;subgrid&lt;/code&gt;, and &lt;code&gt;@layer&lt;/code&gt; all ship in Chrome, Safari, and Firefox. CSS anchor positioning and &lt;code&gt;calc-size()&lt;/code&gt; are still Chromium-only, so I use them only as progressive enhancement.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When should I use container queries instead of media queries?
&lt;/h2&gt;

&lt;p&gt;Use a container query whenever the thing you are styling can appear at more than one column width; keep media queries for page-level concerns. A container query is a CSS rule written with &lt;code&gt;@container&lt;/code&gt; that resolves against the size of the nearest ancestor which declares &lt;code&gt;container-type&lt;/code&gt; — not against the viewport.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.card-grid&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;container-type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inline-size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;container-name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cards&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card&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;grid&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;0.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@container&lt;/span&gt; &lt;span class="n"&gt;cards&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30rem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.card&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8rem&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nc"&gt;.card__title&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="n"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="n"&gt;cqi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1.5rem&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 deleted a component I had shipped in three codebases: a wrapper that used &lt;code&gt;ResizeObserver&lt;/code&gt; to measure its own width and toggle a &lt;code&gt;.is-narrow&lt;/code&gt; class. That wrapper is always one frame late, because it reads layout after the browser has already painted — the component renders wide, then snaps narrow. A container query is resolved during layout, so there is no intermediate frame to see.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;container-type: inline-size&lt;/code&gt; means only the inline axis is queryable and the element gets inline-axis size containment. The practical consequence: a container declared that way can no longer be sized by the width of its own contents, which is what breaks shrink-to-fit elements when people first adopt it. Container query units follow the same axis — &lt;code&gt;1cqi&lt;/code&gt; is one percent of the container's inline size, which is what makes the &lt;code&gt;clamp()&lt;/code&gt; above scale type per card rather than per viewport.&lt;/p&gt;

&lt;p&gt;The gotcha that cost me the most time: an element cannot query itself. If &lt;code&gt;container-type&lt;/code&gt; lives on &lt;code&gt;.card&lt;/code&gt;, a &lt;code&gt;@container&lt;/code&gt; rule targeting &lt;code&gt;.card&lt;/code&gt; will never match. The container must be an ancestor of everything the query styles.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the :has() selector actually replace?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;:has()&lt;/code&gt; replaces the JavaScript that added a class to a parent because of something inside it. It is a relational pseudo-class: &lt;code&gt;A:has(B)&lt;/code&gt; selects element &lt;code&gt;A&lt;/code&gt; when a descendant (or, with a combinator, a child or sibling) matching &lt;code&gt;B&lt;/code&gt; exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* the wrapper reacts to the input inside it */&lt;/span&gt;
&lt;span class="nc"&gt;.field&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;&lt;span class="nd"&gt;:user-invalid&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;--field-border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#c0392b&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* layout changes only when the card really has a media child */&lt;/span&gt;
&lt;span class="nc"&gt;.card&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;img&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;grid-template-rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;/* page-level scroll lock with no event listener */&lt;/span&gt;
&lt;span class="nt"&gt;body&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nt"&gt;dialog&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;open&lt;/span&gt;&lt;span class="o"&gt;])&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last rule removed a scroll-lock utility I had maintained for years — the one that stored &lt;code&gt;window.scrollY&lt;/code&gt;, set &lt;code&gt;position: fixed&lt;/code&gt; on the body, and restored it on close. &lt;code&gt;:has()&lt;/code&gt; is live: it re-evaluates as &lt;code&gt;[open]&lt;/code&gt;, &lt;code&gt;:checked&lt;/code&gt;, and &lt;code&gt;:user-invalid&lt;/code&gt; change, so form and dialog state can drive layout with no listener at all.&lt;/p&gt;

&lt;p&gt;Two rules worth internalizing. First, &lt;code&gt;:has()&lt;/code&gt; takes the specificity of its most specific argument, so &lt;code&gt;.card:has(#hero)&lt;/code&gt; carries an ID's weight — wrap the argument in &lt;code&gt;:where()&lt;/code&gt; when you want it to stay cheap. Second, &lt;code&gt;:has()&lt;/code&gt; cannot be nested inside another &lt;code&gt;:has()&lt;/code&gt; and cannot contain pseudo-elements. On performance, the blanket "&lt;code&gt;:has()&lt;/code&gt; is slow" advice is dated; the engines optimize it, and I keep the subject narrow — &lt;code&gt;.card:has(&amp;gt; img)&lt;/code&gt;, never &lt;code&gt;*:has(img)&lt;/code&gt; — rather than avoiding it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When do I need subgrid instead of a nested grid?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;subgrid&lt;/code&gt; when children of &lt;em&gt;separate&lt;/em&gt; grid items must line up with each other. A nested &lt;code&gt;display: grid&lt;/code&gt; creates its own independent tracks sized by its own content, so three cards with different title lengths produce three different internal layouts. &lt;code&gt;grid-template-rows: subgrid&lt;/code&gt; makes the child adopt the parent's row lines instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.cards&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;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&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;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;.card&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;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;grid-row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c"&gt;/* claim three parent rows */&lt;/span&gt;
  &lt;span class="py"&gt;grid-template-rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;subgrid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c"&gt;/* and adopt their lines */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two lines work together: &lt;code&gt;grid-row: span 3&lt;/code&gt; makes the card occupy three rows of the parent grid, and &lt;code&gt;subgrid&lt;/code&gt; tells the card to use those three row lines for its own children. Every card's title sits on the same line, every footer sits on the same line, and no card needs a fixed height. The alternatives I used before were all worse — hard-coded heights, &lt;code&gt;-webkit-line-clamp&lt;/code&gt; to force titles to one length, or a JavaScript pass that measured the tallest card and wrote a pixel height onto the rest.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;subgrid&lt;/code&gt; is per-axis: you can subgrid rows while defining your own columns, or the reverse. Gaps are inherited from the parent grid unless the subgrid explicitly sets its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do cascade layers and &lt;a class="mentioned-user" href="https://dev.to/scope"&gt;@scope&lt;/a&gt; stop specificity wars?
&lt;/h2&gt;

&lt;p&gt;Cascade layers make override order an explicit declaration instead of an emergent property of your selectors. Rules in a later layer beat rules in an earlier layer regardless of specificity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@layer&lt;/span&gt; &lt;span class="n"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;components&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;utilities&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="sx"&gt;url("vendor.css")&lt;/span&gt; &lt;span class="n"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vendor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;@layer&lt;/span&gt; &lt;span class="n"&gt;components&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.nav&lt;/span&gt; &lt;span class="nc"&gt;.nav__link.is-active&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="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--brand&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c"&gt;/* specificity 0,3,0 */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@layer&lt;/span&gt; &lt;span class="n"&gt;utilities&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.text-muted&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="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--muted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;               &lt;span class="c"&gt;/* 0,1,0 — still wins */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single most important rule about layers is the one that bites people: &lt;strong&gt;unlayered CSS beats every layer.&lt;/strong&gt; One third-party stylesheet loaded outside a layer outranks your entire carefully ordered cascade, which is why the &lt;code&gt;@import ... layer(vendor)&lt;/code&gt; line above matters. The second trap is that &lt;code&gt;!important&lt;/code&gt; reverses layer order, so an &lt;code&gt;!important&lt;/code&gt; declaration in an early layer beats a normal declaration in a later one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@scope&lt;/code&gt; is the companion feature: &lt;code&gt;@scope (.card) to (.card__content)&lt;/code&gt; applies rules only between a root and a lower boundary, which is real component isolation without BEM-length class names. It landed later than the other four features and Firefox was the last engine to ship it, so check current Baseline status before making it load-bearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which CSS feature replaces which JavaScript?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;JavaScript I deleted&lt;/th&gt;
&lt;th&gt;CSS that replaced it&lt;/th&gt;
&lt;th&gt;In every engine since&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ResizeObserver&lt;/code&gt; + class toggle for component breakpoints&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;container-type&lt;/code&gt; + &lt;code&gt;@container&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Firefox 110 (Feb 2023)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parent class toggles driven by child or form state&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:has()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Firefox 121 (Dec 2023)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Measuring the tallest card and writing pixel heights&lt;/td&gt;
&lt;td&gt;&lt;code&gt;grid-template-rows: subgrid&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Chrome 117 (Sept 2023)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Specificity hacks, &lt;code&gt;!important&lt;/code&gt; chains, injection-order tricks&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@layer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Safari 15.4 / Chrome 99 (2022)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scroll-lock utility that saved and restored scroll position&lt;/td&gt;
&lt;td&gt;&lt;code&gt;body:has(dialog[open]) { overflow: hidden }&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Firefox 121 (Dec 2023)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What is still not safe to ship in 2026?
&lt;/h2&gt;

&lt;p&gt;CSS anchor positioning and keyword size interpolation are still Chromium-only, so I ship them only where losing them degrades cleanly. Anchor positioning — &lt;code&gt;anchor-name&lt;/code&gt;, &lt;code&gt;position-anchor&lt;/code&gt;, &lt;code&gt;position-area&lt;/code&gt; — tethers a popover to its trigger without a JavaScript positioning library, and it has been in Chrome since version 125 without matching Safari and Firefox releases. &lt;code&gt;interpolate-size: allow-keywords&lt;/code&gt; plus &lt;code&gt;calc-size()&lt;/code&gt; finally animates &lt;code&gt;height: auto&lt;/code&gt;, and it is in the same Chromium-only bucket.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@supports&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;anchor-name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;--trigger&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.popover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;position-anchor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;--trigger&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;position-area&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;block-end&lt;/span&gt; &lt;span class="n"&gt;span-inline-end&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;For expanding panels I use the grid-fraction trick as the everywhere-baseline, and treat &lt;code&gt;calc-size()&lt;/code&gt; as a bonus rather than the mechanism:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.accordion&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;grid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="py"&gt;grid-template-rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;grid-template-rows&lt;/span&gt; &lt;span class="m"&gt;200ms&lt;/span&gt; &lt;span class="n"&gt;ease&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.accordion&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-open&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;grid-template-rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.accordion&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;.accordion__inner&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule I follow: if a feature changes information architecture — whether the user can read the content or complete the task — it has to be supported in all three engines. If it only changes polish, I gate it behind &lt;code&gt;@supports&lt;/code&gt; and let older engines get the plain version. That line has kept me from shipping a popover that lands in the wrong corner on Safari.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q: Do container queries replace media queries entirely?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. Container queries size a component against its container; media queries still own page-level and environment concerns the container cannot know about — overall page layout, &lt;code&gt;prefers-reduced-motion&lt;/code&gt;, &lt;code&gt;prefers-color-scheme&lt;/code&gt;, and print styles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Is the :has() selector slow?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Treating &lt;code&gt;:has()&lt;/code&gt; as automatically expensive is outdated advice; modern engines optimize it. Keep the subject narrow — prefer &lt;code&gt;.card:has(&amp;gt; img)&lt;/code&gt; over &lt;code&gt;*:has(img)&lt;/code&gt; — and profile style recalculation in DevTools if a specific page feels slow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why doesn't my container query match anything?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Almost always because the element is trying to query itself. &lt;code&gt;@container&lt;/code&gt; resolves against an &lt;em&gt;ancestor&lt;/em&gt; that declares &lt;code&gt;container-type&lt;/code&gt;, never against the element the rule styles. Move &lt;code&gt;container-type&lt;/code&gt; onto a wrapper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What is the difference between subgrid and a nested grid?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; A nested &lt;code&gt;display: grid&lt;/code&gt; creates independent tracks sized by its own content. &lt;code&gt;grid-template-rows: subgrid&lt;/code&gt; adopts the parent's track lines, so siblings align with each other instead of each sizing itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Do cascade layers work with Tailwind CSS or CSS-in-JS?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes — Tailwind CSS v4 organizes its own output into &lt;code&gt;@layer theme, base, components, utilities&lt;/code&gt;. The rule to remember is that unlayered CSS beats every layer, so pull third-party stylesheets into a named layer with &lt;code&gt;@import url("x.css") layer(vendor)&lt;/code&gt; if you need to override them.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/modern-css-container-queries-has-subgrid-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/modern-css-container-queries-has-subgrid-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>css</category>
      <category>webdev</category>
      <category>frontend</category>
      <category>html</category>
    </item>
    <item>
      <title>Background Jobs on Vercel in 2026: Field Notes on waitUntil, Queues, Workflow, and Cron</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Mon, 10 Aug 2026 06:02:32 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/background-jobs-on-vercel-in-2026-field-notes-on-waituntil-queues-workflow-and-cron-1l6g</link>
      <guid>https://dev.to/ahmed_mahmoud360/background-jobs-on-vercel-in-2026-field-notes-on-waituntil-queues-workflow-and-cron-1l6g</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Serverless did not kill background work — it killed background work that outlives the response without telling the runtime. I now route every deferred task on Vercel through one of four primitives: &lt;code&gt;waitUntil()&lt;/code&gt; for short best-effort side effects, Vercel Cron for clock-triggered sweeps, Vercel Queues for work that must survive a failing consumer, and Vercel Workflow for multi-step jobs that must survive a redeploy an hour later.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;waitUntil()&lt;/code&gt; from the &lt;code&gt;@vercel/functions&lt;/code&gt; package extends a Vercel Function past its response so a pending promise can finish, but it is best-effort: no retries, no durability, and it dies with the invocation.&lt;/li&gt;
&lt;li&gt;Vercel Queues is a durable event-streaming service with &lt;strong&gt;at-least-once&lt;/strong&gt; delivery, which means every consumer must be idempotent — some message will eventually be delivered twice.&lt;/li&gt;
&lt;li&gt;Vercel Workflow provides durable execution: an async function marked with the &lt;code&gt;"use workflow"&lt;/code&gt; directive checkpoints each step, so a crash resumes at the last completed step instead of restarting from the top.&lt;/li&gt;
&lt;li&gt;Vercel Cron is correct only for time-triggered work. A job triggered by a user action belongs in a queue, not on a schedule.&lt;/li&gt;
&lt;li&gt;Vercel Functions default to a 300-second max duration on all plans in 2026, so a surprising amount of "this obviously needs a queue" work now fits inside a single invocation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does background work disappear after I return a response?
&lt;/h2&gt;

&lt;p&gt;Background work disappears because an unawaited promise has no owner. When a Vercel Function returns a &lt;code&gt;Response&lt;/code&gt;, the platform is free to freeze or reclaim that instance immediately. Any promise still in flight is not tracked by anything, so it is cancelled at an arbitrary point.&lt;/p&gt;

&lt;p&gt;The failure mode that cost me the most debugging time is not that the work never runs — it is that the work runs &lt;em&gt;sometimes&lt;/em&gt;. Fluid Compute, the default compute model on Vercel, reuses a single function instance across concurrent requests instead of spinning up one instance per request. A dangling promise therefore often completes, because another request keeps the instance warm. Under low traffic it silently vanishes. Non-deterministic loss is far harder to notice in production than total loss.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wrong: this promise is unowned and may be killed mid-flight.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&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;logToAnalytics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// no await, no waitUntil — dangling&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;h2&gt;
  
  
  When is waitUntil() enough?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;waitUntil()&lt;/code&gt; is enough when losing the work occasionally is acceptable and the work finishes in single-digit seconds. The function &lt;code&gt;waitUntil(promise)&lt;/code&gt; is exported from &lt;code&gt;@vercel/functions&lt;/code&gt; and registers a promise with the runtime, so the invocation stays alive until that promise settles even though the response has already been sent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;waitUntil&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;@vercel/functions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&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;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;logToAnalytics&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// owned by the runtime now&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;Two honest limits I hit. First, &lt;code&gt;waitUntil()&lt;/code&gt; has no retry semantics: if the promise rejects, nothing re-runs it, and the rejection surfaces only in runtime logs. Second, the deferred work still counts against the function's max duration and against Active CPU billing — &lt;code&gt;waitUntil()&lt;/code&gt; defers the work relative to the &lt;em&gt;response&lt;/em&gt;, not relative to the &lt;em&gt;invocation&lt;/em&gt;. I use it for analytics events, cache warming, and log shipping. I do not use it for anything a user would file a support ticket about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does Vercel Queues actually solve?
&lt;/h2&gt;

&lt;p&gt;Vercel Queues solves the case where the work must eventually happen even if the first attempt fails. Vercel Queues is a durable event-streaming system built on Fluid Compute, currently in public beta, that provides at-least-once delivery: a producer writes a message to a topic and returns immediately, and a separate consumer function processes that message with retries on failure.&lt;/p&gt;

&lt;p&gt;The architectural win is decoupling latency budgets. Before a queue, the p95 of my API route was the p95 of the slowest third party it called — an email provider, a PDF renderer, a webhook fan-out. After a queue, the route's p95 is the cost of one durable write, and the third party's bad afternoon becomes a retry curve on the consumer instead of a timeout on the user's request.&lt;/p&gt;

&lt;p&gt;The tax is idempotency, and it is not optional. At-least-once delivery means duplicate delivery is a certainty over a long enough window, not an edge case. My default pattern is a dedupe table with a unique constraint on the message id, written before the side effect runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Consumer: insert-then-act. The unique index is the guard.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;processedMessages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;messageId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onConflictDoNothing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;processedMessages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inserted&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;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// already handled, ack and move on&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendReceiptEmail&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Queues API surface is still moving while it is in public beta, so treat the shape above as the pattern rather than a frozen signature and check the current docs before wiring it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should I use Vercel Workflow instead of a queue?
&lt;/h2&gt;

&lt;p&gt;Use Vercel Workflow when the retry unit is a single step inside a longer job, not the whole message. Vercel Workflow is a durable execution framework: you mark an async function with the &lt;code&gt;"use workflow"&lt;/code&gt; directive and its individual steps with &lt;code&gt;"use step"&lt;/code&gt;, and the runtime checkpoints each completed step's result so an interruption resumes from the last checkpoint instead of re-running everything.&lt;/p&gt;

&lt;p&gt;That distinction is the whole decision for me. A queue message is atomic — if the handler throws on line 40, the entire message is redelivered and lines 1 through 39 run again. That is fine when those lines are pure. It is not fine when line 12 charged a card and line 40 failed to render a PDF.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use workflow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onboardCustomer&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;account&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;createAccount&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="c1"&gt;// step 1, checkpointed&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;provisionResources&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// step 2, checkpointed&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;24 hours&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                         &lt;span class="c1"&gt;// survives a redeploy&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendDayTwoEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;               &lt;span class="c1"&gt;// step 4&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other thing Workflow buys is time. A durable workflow can sleep for hours or days and wait for an external event, because its state lives outside any single function invocation. A queue consumer cannot — it is still a function bounded by the 300-second max duration.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I pick between cron, waitUntil, Queues, and Workflow?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Primitive&lt;/th&gt;
&lt;th&gt;Trigger&lt;/th&gt;
&lt;th&gt;Durable?&lt;/th&gt;
&lt;th&gt;Retries&lt;/th&gt;
&lt;th&gt;Use it for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;waitUntil()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Request&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Analytics pings, cache warming, log shipping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vercel Cron&lt;/td&gt;
&lt;td&gt;Clock&lt;/td&gt;
&lt;td&gt;Yes (the schedule)&lt;/td&gt;
&lt;td&gt;Next tick&lt;/td&gt;
&lt;td&gt;Nightly sweeps, expiry, report generation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vercel Queues&lt;/td&gt;
&lt;td&gt;Producer message&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;At-least-once redelivery&lt;/td&gt;
&lt;td&gt;Email sends, webhook fan-out, image processing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vercel Workflow&lt;/td&gt;
&lt;td&gt;Explicit invocation&lt;/td&gt;
&lt;td&gt;Yes (per step)&lt;/td&gt;
&lt;td&gt;Per step, resumes at checkpoint&lt;/td&gt;
&lt;td&gt;Onboarding sequences, multi-provider orchestration, long jobs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Vercel Cron is configured declaratively. In &lt;code&gt;vercel.ts&lt;/code&gt;, the recommended TypeScript project configuration that replaces &lt;code&gt;vercel.json&lt;/code&gt;, it is a &lt;code&gt;crons&lt;/code&gt; array of path-and-schedule pairs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;VercelConfig&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;@vercel/config/v1&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;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VercelConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;crons&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/cleanup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0 3 * * *&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What actually broke for me?
&lt;/h2&gt;

&lt;p&gt;Three things broke, all of them in the gap between "the primitive works" and "my handler respects the primitive's contract."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A duplicate side effect from at-least-once delivery.&lt;/strong&gt; A consumer that sent a confirmation email had no dedupe guard. A transient failure after the send but before the ack caused redelivery, and the same user got the same email twice. The fix was the insert-then-act pattern above: write the message id under a unique constraint first, and treat a conflict as "already done."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A long job parked in &lt;code&gt;waitUntil()&lt;/code&gt;.&lt;/strong&gt; I put a multi-minute document job behind &lt;code&gt;waitUntil()&lt;/code&gt; because it was the smallest diff. It worked in staging and lost work in production during deploys, because a rolling deploy retires the old instance and the in-flight promise goes with it. That job belonged in a queue from day one; &lt;code&gt;waitUntil()&lt;/code&gt; was the wrong contract, not a tuning problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Overlapping cron runs.&lt;/strong&gt; A nightly sweep that normally took a few minutes grew past its own interval, and two invocations ran concurrently over the same rows. Vercel Cron does not serialize overlapping executions for you. I added a Postgres advisory lock at the top of the handler and made the second run exit immediately instead of contending.&lt;/p&gt;

&lt;p&gt;The pattern behind all three: pick the primitive by the failure you can tolerate, not by the code you can write fastest. Best-effort work gets &lt;code&gt;waitUntil()&lt;/code&gt;. Must-happen work gets a queue and an idempotency key. Must-happen-in-order work gets a workflow. Clock work gets cron and a lock.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does &lt;code&gt;waitUntil()&lt;/code&gt; let a Vercel Function run longer than its max duration?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. &lt;code&gt;waitUntil()&lt;/code&gt; keeps the invocation alive after the response is sent, but the invocation is still bounded by the function's max duration, which defaults to 300 seconds on all plans in 2026. It defers work relative to the response, not relative to the invocation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need a third-party queue like SQS, BullMQ, or Inngest on Vercel?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Not for the common cases. Vercel Queues covers durable at-least-once messaging and Vercel Workflow covers durable multi-step execution, both natively on Fluid Compute. Reach for an external system when you need semantics they do not offer, such as strict FIFO ordering per key or exactly-once processing enforced by the broker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What does at-least-once delivery mean in practice for my consumer code?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; It means your consumer will receive the same message more than once at some point, so every side effect must be safe to repeat. Guard non-idempotent effects — charges, emails, external POSTs — with a dedupe record keyed by the message id and written under a unique constraint before the effect runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can Vercel Cron trigger a queue producer instead of doing the work itself?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes, and that is usually the better design for large sweeps. Have the cron route enumerate the work and publish one message per item, then let queue consumers process items in parallel with independent retries. The cron invocation stays short and a single bad item cannot fail the entire sweep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does Fluid Compute change how I should write background work?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes, in one specific way: Fluid Compute reuses instances across concurrent requests, so dangling promises often complete by accident. That makes unowned background work look correct in testing and fail intermittently in production. Always register deferred work explicitly with &lt;code&gt;waitUntil()&lt;/code&gt; or hand it to a queue.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/background-jobs-vercel-queues-workflow-cron-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/background-jobs-vercel-queues-workflow-cron-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>vercel</category>
      <category>node</category>
      <category>serverless</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Running AI-Generated Code Safely: Field Notes on Vercel Sandbox</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Sun, 09 Aug 2026 06:01:49 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/running-ai-generated-code-safely-field-notes-on-vercel-sandbox-3g4e</link>
      <guid>https://dev.to/ahmed_mahmoud360/running-ai-generated-code-safely-field-notes-on-vercel-sandbox-3g4e</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Vercel Sandbox runs untrusted code — including code a model just wrote — inside an isolated, ephemeral microVM instead of inside my application's own process. I moved every "let the model write and execute a snippet" feature off ad-hoc &lt;code&gt;child_process&lt;/code&gt; calls and onto Sandbox: one sandbox per execution, a hard timeout, an isolated filesystem, and no path back into my app's environment variables.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;Vercel Sandbox (&lt;code&gt;@vercel/sandbox&lt;/code&gt;) runs code inside an isolated Firecracker microVM, not a container in your app's own process — a compromised sandbox can't read your Vercel Function's memory or environment variables.&lt;/li&gt;
&lt;li&gt;A sandbox is ephemeral: you create one, run commands, read the output, then stop it. There is no persistent state between runs unless you explicitly persist it yourself.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sandbox.runCommand()&lt;/code&gt; executes a process inside the sandbox and returns stdout, stderr, and an exit code; &lt;code&gt;sandbox.domain(port)&lt;/code&gt; exposes a running server on a public URL for a live preview.&lt;/li&gt;
&lt;li&gt;The two cases I actually reach for it: an LLM-authored script that needs to run and return a result, and a user-facing "run this code" feature like an AI-generated component preview.&lt;/li&gt;
&lt;li&gt;Sandbox is not the tool for trusted, first-party build or CI logic — that belongs in the deploy pipeline. Sandbox is for code you did not write and do not trust.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why can't I just run AI-generated code inside my own Vercel Function?
&lt;/h2&gt;

&lt;p&gt;A Vercel Function shares its process, filesystem, and environment with the rest of my app. Running an untrusted string as code in that same process — through &lt;code&gt;child_process.exec&lt;/code&gt; or, worse, &lt;code&gt;eval&lt;/code&gt; — puts every secret the function can see, API keys and database URLs included, inside the blast radius of whatever the model wrote. A generated snippet can read environment variables, open an outbound connection to exfiltrate them, or just spin the CPU and starve every other request the function is serving at the same time.&lt;/p&gt;

&lt;p&gt;I treat any code I did not author myself as untrusted by default, and that includes code a model generates on request. Untrusted code needs its own compute boundary: its own filesystem, its own network context, and resource limits I can enforce and then throw away.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Vercel Sandbox actually running under the hood?
&lt;/h2&gt;

&lt;p&gt;Vercel Sandbox provisions a Firecracker microVM for every sandbox — the same virtualization technology AWS Lambda uses to isolate tenants from each other, not a namespace or cgroup container. The practical difference is the escape hatch: breaking out of a container means crossing a kernel-namespace boundary inside a kernel the workload shares with its neighbors, while breaking out of a microVM means finding a hypervisor-level exploit against a kernel nothing else is using. Creating one is a single call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Sandbox&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;@vercel/sandbox&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;sandbox&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;Sandbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;runtime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node22&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// ms — hard ceiling before Vercel force-stops it&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;vcpus&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;runtime&lt;/code&gt; picks the base image, &lt;code&gt;timeout&lt;/code&gt; is a hard ceiling I set per use case, and &lt;code&gt;resources.vcpus&lt;/code&gt; controls how much CPU the microVM gets. I set the shortest timeout a feature can tolerate rather than reusing one default everywhere — a code-eval playground gets seconds, a batch-style job gets minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I actually execute an LLM-generated snippet inside a sandbox?
&lt;/h2&gt;

&lt;p&gt;Write the generated code to a file inside the sandbox, then run it as a subprocess — never pass model output through &lt;code&gt;eval&lt;/code&gt; or the &lt;code&gt;Function&lt;/code&gt; constructor inside your own function, sandboxed or not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sandbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFiles&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;snippet.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;generatedCode&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sandbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;args&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;snippet.js&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;stdout&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stdout&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;exitCode&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;exitCode&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;runCommand()&lt;/code&gt; gives me back exactly what a subprocess call would: stdout, stderr, and an exit code. The difference is where that process actually ran — inside a disposable microVM instead of next to my app's live secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does a Vercel Sandbox keep state between runs?
&lt;/h2&gt;

&lt;p&gt;No. Sandboxes are ephemeral by design — each &lt;code&gt;Sandbox.create()&lt;/code&gt; call provisions a fresh microVM with a clean filesystem, and calling &lt;code&gt;sandbox.stop()&lt;/code&gt;, or hitting the timeout, tears it down completely, including anything written to disk. If a feature needs to remember something across runs — a multi-turn code-interpreter chat, for instance — that state has to live outside the sandbox: write results to a database or blob store from inside the sandboxed process, or persist a small manifest the caller rehydrates into a new sandbox next time. I treat each sandbox as disposable compute, never as a place to store anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can I stream a sandbox's output back to the browser while it's running?
&lt;/h2&gt;

&lt;p&gt;Yes. &lt;code&gt;runCommand()&lt;/code&gt; accepts a &lt;code&gt;detached&lt;/code&gt; option, which returns a handle you can read from as output is produced instead of waiting for the whole command to finish — the same pattern I use for streaming a model's token output, just piping a sandbox's stdout instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;sandbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;args&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;agent.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;detached&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;for&lt;/span&gt; &lt;span class="k"&gt;await &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;chunk&lt;/span&gt; &lt;span class="k"&gt;of&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;stdout&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// forward to a ReadableStream response&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of this needs the edge runtime — streaming a sandbox's output back through a Vercel Function works on the default Node.js runtime with no extra config, the same as streaming an LLM response.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the actual difference between Sandbox and child_process in a Function?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;child_process in a Function&lt;/th&gt;
&lt;th&gt;Vercel Sandbox&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Isolation boundary&lt;/td&gt;
&lt;td&gt;Same process and container as your app&lt;/td&gt;
&lt;td&gt;Separate Firecracker microVM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filesystem&lt;/td&gt;
&lt;td&gt;Shares the function's filesystem&lt;/td&gt;
&lt;td&gt;Isolated, wiped on stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network egress&lt;/td&gt;
&lt;td&gt;Shares the function's network context&lt;/td&gt;
&lt;td&gt;Its own network namespace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blast radius of a crash or hang&lt;/td&gt;
&lt;td&gt;Can take the whole function down&lt;/td&gt;
&lt;td&gt;Contained to the sandbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good for&lt;/td&gt;
&lt;td&gt;Trusted, first-party subprocess calls you wrote yourself&lt;/td&gt;
&lt;td&gt;Untrusted, LLM-authored, or user-submitted code&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When should I not reach for Vercel Sandbox?
&lt;/h2&gt;

&lt;p&gt;Not for code I trust and wrote myself — provisioning a microVM adds real latency compared to a subprocess in an already-warm function, and there's no isolation benefit to pay that cost for. Not for a full CI or build pipeline either; that belongs in the platform's own build step, not a runtime sandbox. I reach for Sandbox specifically when the code executing is either model-generated or submitted by a user I don't trust, and the feature genuinely needs to run something — a subprocess, a filesystem, an arbitrary language — rather than just call an LLM API and return text.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is Vercel Sandbox the same thing as a Vercel Function?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. A Vercel Function runs your own deployed code with your app's environment and network context. A Sandbox is a separate, ephemeral microVM you provision at runtime specifically to execute code you don't trust, with its own filesystem and no access to your function's secrets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What isolation technology does Vercel Sandbox use?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Firecracker microVMs — each sandbox gets its own kernel, not just a container namespace, the same class of isolation AWS Lambda uses between tenants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can a sandbox access my environment variables or database?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Not unless you explicitly pass them in. A sandbox starts with a clean environment, and I only ever inject scoped, short-lived credentials into a sandbox that's about to run untrusted code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How long can a sandbox run?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; You set a timeout when you create it, and the sandbox is force-stopped once that timeout hits. I set the shortest timeout the feature can tolerate — seconds for a "run this snippet" playground, longer for batch-style jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Should I use Sandbox to run my own build scripts?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No — that's what the deploy pipeline is for. Sandbox earns its cost, microVM provisioning latency and no persistent state, specifically for code you did not write: LLM output, user-submitted snippets, anything where the isolation boundary is the point.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/vercel-sandbox-ai-generated-code-execution-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/vercel-sandbox-ai-generated-code-execution-field-notes" rel="noopener noreferrer"&gt;eng-ahmed.com&lt;/a&gt;. Built by &lt;a href="https://www.devya.dev" rel="noopener noreferrer"&gt;Devya Solutions&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>vercel</category>
      <category>ai</category>
      <category>node</category>
      <category>security</category>
    </item>
  </channel>
</rss>
