<?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>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>
    <item>
      <title>Node.js Has a Test Runner Now: Field Notes on Dropping Jest for Scripts and Libraries</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Sat, 08 Aug 2026 06:32:54 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/nodejs-has-a-test-runner-now-field-notes-on-dropping-jest-for-scripts-and-libraries-i89</link>
      <guid>https://dev.to/ahmed_mahmoud360/nodejs-has-a-test-runner-now-field-notes-on-dropping-jest-for-scripts-and-libraries-i89</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Node.js ships a built-in test runner — &lt;code&gt;node --test&lt;/code&gt; executes &lt;code&gt;node:test&lt;/code&gt; files with no Jest, no Vitest, and no config file — and paired with type stripping it runs &lt;code&gt;.ts&lt;/code&gt; tests with zero build. I moved my scripts and small libraries onto it: &lt;code&gt;node:test&lt;/code&gt; for structure, &lt;code&gt;node:assert/strict&lt;/code&gt; for assertions, the built-in &lt;code&gt;mock&lt;/code&gt; for spies and fake timers, and &lt;code&gt;--experimental-test-coverage&lt;/code&gt; for coverage. I kept Vitest for anything that touches the DOM or renders React.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;The Node.js test runner lives in the built-in &lt;code&gt;node:test&lt;/code&gt; module and runs with &lt;code&gt;node --test&lt;/code&gt;; it needs no dependency, no config file, and has been stable since Node.js 20.&lt;/li&gt;
&lt;li&gt;Assertions come from the built-in &lt;code&gt;node:assert&lt;/code&gt; module — import &lt;code&gt;node:assert/strict&lt;/code&gt; so &lt;code&gt;assert.equal&lt;/code&gt; uses strict (&lt;code&gt;===&lt;/code&gt;) comparison.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;mock&lt;/code&gt; object from &lt;code&gt;node:test&lt;/code&gt; gives you &lt;code&gt;mock.fn()&lt;/code&gt;, &lt;code&gt;mock.method()&lt;/code&gt;, and &lt;code&gt;mock.timers&lt;/code&gt; without a separate mocking library; module mocking via &lt;code&gt;mock.module()&lt;/code&gt; is still experimental.&lt;/li&gt;
&lt;li&gt;Combined with type stripping, &lt;code&gt;node --test "src/**/*.test.ts"&lt;/code&gt; runs TypeScript tests with no build step on Node.js 22.18+ and Node.js 24.&lt;/li&gt;
&lt;li&gt;Coverage is available behind &lt;code&gt;--experimental-test-coverage&lt;/code&gt;, and output format is chosen with &lt;code&gt;--test-reporter&lt;/code&gt; (&lt;code&gt;spec&lt;/code&gt;, &lt;code&gt;tap&lt;/code&gt;, &lt;code&gt;dot&lt;/code&gt;, &lt;code&gt;junit&lt;/code&gt;, &lt;code&gt;lcov&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Can Node.js run tests without Jest or Vitest?
&lt;/h2&gt;

&lt;p&gt;Yes — Node.js has a built-in test runner, and it needs zero dependencies. The runner is the &lt;code&gt;node:test&lt;/code&gt; core module, invoked with &lt;code&gt;node --test&lt;/code&gt;; it landed experimentally in Node.js 18 and became stable in Node.js 20. It auto-discovers test files, runs each in its own child process for isolation, and reports in a TAP-based format.&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;// math.test.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;test&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:test&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;assert&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:assert/strict&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;add&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;./math.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;add sums two numbers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;node --test&lt;/code&gt;. No &lt;code&gt;jest.config.js&lt;/code&gt;, no transform, no &lt;code&gt;ts-jest&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I structure tests with node:test?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;node:test&lt;/code&gt; exports &lt;code&gt;test&lt;/code&gt;, plus &lt;code&gt;describe&lt;/code&gt;/&lt;code&gt;it&lt;/code&gt; and the hooks &lt;code&gt;before&lt;/code&gt;, &lt;code&gt;after&lt;/code&gt;, &lt;code&gt;beforeEach&lt;/code&gt;, &lt;code&gt;afterEach&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;beforeEach&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:test&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;assert&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:assert/strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Cart&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;beforeEach&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;cart&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;Cart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;starts empty&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;total&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sums item prices&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;price&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="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;total&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;15&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;Focus one test with &lt;code&gt;{ only: true }&lt;/code&gt; and &lt;code&gt;--test-only&lt;/code&gt;, or filter with &lt;code&gt;--test-name-pattern&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I mock functions and timers?
&lt;/h2&gt;

&lt;p&gt;Mocking is built in through the &lt;code&gt;mock&lt;/code&gt; object — no &lt;code&gt;jest.fn&lt;/code&gt; to install.&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;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mock&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:test&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;assert&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:assert/strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notifies the customer once&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;notify&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;placeOrder&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt; &lt;span class="nx"&gt;notify&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;callCount&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mock.method(obj, 'name')&lt;/code&gt; spies on a real method, and &lt;code&gt;mock.timers.enable({ apis: ['setTimeout'] })&lt;/code&gt; plus &lt;code&gt;mock.timers.tick(1000)&lt;/code&gt; gives deterministic fake timers. Full module mocking via &lt;code&gt;mock.module()&lt;/code&gt; is still experimental, so I favor dependency injection instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can I run TypeScript tests without a build step?
&lt;/h2&gt;

&lt;p&gt;Yes, on Node.js 22.18+ and Node.js 24, because Node.js strips type annotations at load time.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;--test&lt;/span&gt; &lt;span class="s2"&gt;"src/**/*.test.ts"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Relative imports need the explicit &lt;code&gt;.ts&lt;/code&gt; extension, and Node.js never type-checks — run &lt;code&gt;tsc --noEmit&lt;/code&gt; as a separate CI gate. This removed &lt;code&gt;ts-jest&lt;/code&gt; and the Vitest transform from every script and library repo I own.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I get coverage and CI reports?
&lt;/h2&gt;

&lt;p&gt;Watch mode is &lt;code&gt;node --test --watch&lt;/code&gt;. Coverage is a flag: &lt;code&gt;node --test --experimental-test-coverage&lt;/code&gt; prints a per-file table. Reporters are chosen with &lt;code&gt;--test-reporter&lt;/code&gt;, and you can emit several at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;--test&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--test-reporter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;spec &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--test-reporter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;junit &lt;span class="nt"&gt;--test-reporter-destination&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;junit.xml &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--experimental-test-coverage&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thresholds like &lt;code&gt;--test-coverage-lines=80&lt;/code&gt; (Node.js 22+) fail the run below a percentage — enough to gate a PR without a coverage service.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should I keep Vitest or Jest?
&lt;/h2&gt;

&lt;p&gt;Keep them whenever a test needs a browser-like environment or a build transform the runtime lacks.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Node.js test runner&lt;/th&gt;
&lt;th&gt;Vitest / Jest&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dependencies&lt;/td&gt;
&lt;td&gt;Zero (built in)&lt;/td&gt;
&lt;td&gt;A framework + transform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DOM / React&lt;/td&gt;
&lt;td&gt;No jsdom, no JSX&lt;/td&gt;
&lt;td&gt;jsdom/happy-dom + JSX&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript&lt;/td&gt;
&lt;td&gt;Native via type stripping&lt;/td&gt;
&lt;td&gt;esbuild/SWC transform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Snapshots&lt;/td&gt;
&lt;td&gt;Basic, recent&lt;/td&gt;
&lt;td&gt;Mature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Scripts, libraries, backend&lt;/td&gt;
&lt;td&gt;Frontend, components, big suites&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Backend and library code gets the built-in runner; anything that renders a component stays on Vitest.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q: Is the Node.js test runner production-ready?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes — the runner and &lt;code&gt;node:test&lt;/code&gt; API are stable since Node.js 20. Only module mocking and coverage remain behind experimental flags.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Do I need a config file?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. &lt;code&gt;node --test&lt;/code&gt; auto-discovers test files and takes options as CLI flags.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Which assertion library does it use?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; The built-in &lt;code&gt;node:assert&lt;/code&gt;; import &lt;code&gt;node:assert/strict&lt;/code&gt; for strict equality. Third-party assertion libraries still work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can it test React components?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Not well — no DOM, no JSX transform. Use Vitest/Jest with jsdom for components.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: How does it mock ES modules?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Via &lt;code&gt;mock.module()&lt;/code&gt;, still experimental. I prefer dependency injection for stable code.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/nodejs-native-test-runner-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/nodejs-native-test-runner-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>node</category>
      <category>testing</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Getting Typed JSON Out of LLMs: Field Notes on generateObject</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Fri, 07 Aug 2026 06:01:52 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/getting-typed-json-out-of-llms-field-notes-on-generateobject-45hk</link>
      <guid>https://dev.to/ahmed_mahmoud360/getting-typed-json-out-of-llms-field-notes-on-generateobject-45hk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; The Vercel AI SDK's &lt;code&gt;generateObject&lt;/code&gt; is the reliable way to get typed, schema-validated JSON out of a language model: I pass a Zod schema, the SDK constrains the model and validates the result, and I get a typed object instead of hand-parsing a string that is JSON &lt;em&gt;most&lt;/em&gt; of the time. Four things carried the weight for me — &lt;code&gt;generateObject&lt;/code&gt; for one-shot extraction, &lt;code&gt;streamObject&lt;/code&gt; for progressive UI, the object/array/enum/no-schema output modes, and treating &lt;code&gt;NoObjectGeneratedError&lt;/code&gt; as a first-class code path.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;generateObject({ model, schema, prompt })&lt;/code&gt; returns a typed object validated against a Zod (or JSON) schema; on a schema mismatch it throws instead of handing you bad data.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;streamObject&lt;/code&gt; streams a partial object through &lt;code&gt;partialObjectStream&lt;/code&gt; so a form or table fills in field-by-field before the model finishes.&lt;/li&gt;
&lt;li&gt;The AI SDK has four output modes: &lt;code&gt;object&lt;/code&gt; (default), &lt;code&gt;array&lt;/code&gt; (streams elements via &lt;code&gt;elementStream&lt;/code&gt;), &lt;code&gt;enum&lt;/code&gt; (single-label classification), and &lt;code&gt;no-schema&lt;/code&gt; (freeform JSON).&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;generateObject&lt;/code&gt; to extract data and tool calling to take actions; &lt;code&gt;experimental_output&lt;/code&gt; combines a tool-calling loop with a final typed object.&lt;/li&gt;
&lt;li&gt;When a model returns invalid JSON the SDK throws &lt;code&gt;NoObjectGeneratedError&lt;/code&gt;, which carries the raw &lt;code&gt;text&lt;/code&gt; and &lt;code&gt;usage&lt;/code&gt;; &lt;code&gt;experimental_repairText&lt;/code&gt; and tighter schema descriptions recover most of those.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does generateObject actually do?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;generateObject&lt;/code&gt; is a Vercel AI SDK function that forces a language model to return JSON matching a schema I define, and validates the response before my code ever sees it. I pass a model, a Zod schema, and a prompt; I get back a typed object whose shape TypeScript already knows.&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;generateObject&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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;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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateObject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anthropic/claude-sonnet-5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="na"&gt;tags&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;array&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;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="na"&gt;sentiment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;positive&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;neutral&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;negative&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;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Summarise this support ticket: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ticket&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sentiment&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// typed as 'positive' | 'neutral' | 'negative'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schema does double duty: it steers the model toward the right shape, and it validates the output. If the model returns a field that does not parse, &lt;code&gt;generateObject&lt;/code&gt; throws rather than returning half-valid data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just parse JSON from generateText?
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;JSON.parse&lt;/code&gt; on a &lt;code&gt;generateText&lt;/code&gt; string is exactly the failure mode &lt;code&gt;generateObject&lt;/code&gt; exists to remove. A raw completion is a string that is well-formed JSON most of the time — until the model wraps it in a markdown fence, adds a trailing comment, or drops a required field, and the parse throws in production. &lt;code&gt;generateObject&lt;/code&gt; injects the schema into the request, uses each provider's structured-output or tool machinery to constrain generation, and validates the result with your Zod schema before returning.&lt;/p&gt;

&lt;p&gt;The win is not fewer characters of code. It is that the boundary between model output and typed value has one owner — the schema — instead of being smeared across a prompt, a regex, and a &lt;code&gt;try/catch&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should I use streamObject instead of generateObject?
&lt;/h2&gt;

&lt;p&gt;Reach for &lt;code&gt;streamObject&lt;/code&gt; when the object is big enough that waiting for the whole thing feels slow. &lt;code&gt;streamObject&lt;/code&gt; returns a &lt;code&gt;partialObjectStream&lt;/code&gt; that yields the object as it is built, so a UI can render fields the moment they arrive.&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;streamObject&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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;partialObjectStream&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;streamObject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anthropic/claude-sonnet-5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reportSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;prompt&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;partial&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;partialObjectStream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// partial is a deep-partial of Report&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each emitted value is a deep-partial of the schema, so every field can be &lt;code&gt;undefined&lt;/code&gt; until the model fills it. For one-shot server work — a cron job, a route handler that returns once — &lt;code&gt;generateObject&lt;/code&gt; is simpler and stays my default.&lt;/p&gt;

&lt;h2&gt;
  
  
  What output modes does the AI SDK support?
&lt;/h2&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;What you get&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;object&lt;/code&gt; (default)&lt;/td&gt;
&lt;td&gt;one validated object&lt;/td&gt;
&lt;td&gt;extraction, summarization, a single record&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;array&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;elements via &lt;code&gt;elementStream&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;lists where each row renders as it lands&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;enum&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;one string from a fixed set&lt;/td&gt;
&lt;td&gt;classification, routing, yes/no gates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;no-schema&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;arbitrary parsed JSON&lt;/td&gt;
&lt;td&gt;exploratory prompts with an unknown shape&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For &lt;code&gt;enum&lt;/code&gt; I pass &lt;code&gt;output: 'enum'&lt;/code&gt; and an &lt;code&gt;enum: ['spam', 'not_spam']&lt;/code&gt; list; the model can only return one of those exact strings, which is stricter and cheaper than an object with one enum field.&lt;/p&gt;

&lt;h2&gt;
  
  
  When do I use tool calling instead of generateObject?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;generateObject&lt;/code&gt; to extract a value and tool calling to do something. &lt;code&gt;generateObject&lt;/code&gt; has no side effects: it turns unstructured input into one typed object and stops. Tool calling — &lt;code&gt;generateText&lt;/code&gt; or &lt;code&gt;streamText&lt;/code&gt; with a &lt;code&gt;tools&lt;/code&gt; map — lets the model decide to call functions, possibly several times in a loop, before it answers.&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;generateText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Output&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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;experimental_output&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anthropic/claude-sonnet-5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;searchOrders&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;experimental_output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Output&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;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;answerSchema&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="nx"&gt;prompt&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;experimental_output&lt;/code&gt; — still flagged experimental — is the bridge: the model runs its tool-calling loop and then returns a final answer validated against a schema, so I get the actions and a typed result from one call.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I handle a model that returns invalid JSON?
&lt;/h2&gt;

&lt;p&gt;When generation fails schema validation, the AI SDK throws &lt;code&gt;NoObjectGeneratedError&lt;/code&gt;, and catching it explicitly is the difference between a graceful fallback and a 500. The error carries the raw &lt;code&gt;text&lt;/code&gt; the model produced, plus &lt;code&gt;usage&lt;/code&gt; and &lt;code&gt;response&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generateObject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NoObjectGeneratedError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateObject&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;prompt&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;object&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;NoObjectGeneratedError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;logger&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="na"&gt;text&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="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;usage&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="nx"&gt;usage&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 object from model&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;fallback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&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;Three things cut my failure rate before the catch block runs. First, &lt;code&gt;.describe()&lt;/code&gt; on every non-obvious field — the description is sent to the model, so &lt;code&gt;z.string().describe('ISO 8601 date')&lt;/code&gt; beats hoping. Second, &lt;code&gt;.nullable()&lt;/code&gt; over &lt;code&gt;.optional()&lt;/code&gt; for fields the model might not know, because many models emit &lt;code&gt;null&lt;/code&gt; more reliably than they omit a key. Third, &lt;code&gt;experimental_repairText&lt;/code&gt;, a callback that can strip a code fence or trailing comma before the SDK re-parses. I only retry after those three, because a retry doubles latency and cost.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q: What is the difference between generateObject and generateText?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; &lt;code&gt;generateText&lt;/code&gt; returns a free-form string; &lt;code&gt;generateObject&lt;/code&gt; returns a typed object validated against a schema and throws if the output does not match. Use &lt;code&gt;generateText&lt;/code&gt; for prose, &lt;code&gt;generateObject&lt;/code&gt; whenever you need machine-readable data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does generateObject work with any model?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; It works with any provider the AI SDK supports, but the mechanism varies — some use native structured-output JSON mode, others tool calling under the hood. You pass the same Zod schema regardless; models with native structured output are the most reliable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can I stream a structured object to the browser?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. &lt;code&gt;streamObject&lt;/code&gt; returns a &lt;code&gt;partialObjectStream&lt;/code&gt; of deep-partial objects. Render each field as it arrives and treat every field as possibly &lt;code&gt;undefined&lt;/code&gt; until the stream completes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: How do I classify text without an object wrapper?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Use &lt;code&gt;output: 'enum'&lt;/code&gt; with an &lt;code&gt;enum&lt;/code&gt; array of allowed labels. The model must return exactly one of the strings, which is stricter and cheaper than an object with a single enum field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What throws when the model output is malformed?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; &lt;code&gt;NoObjectGeneratedError&lt;/code&gt;. It exposes the raw &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;usage&lt;/code&gt;, and &lt;code&gt;response&lt;/code&gt; so you can log and fall back. Reduce its frequency with field &lt;code&gt;.describe()&lt;/code&gt; hints, &lt;code&gt;.nullable()&lt;/code&gt; over &lt;code&gt;.optional()&lt;/code&gt;, and an &lt;code&gt;experimental_repairText&lt;/code&gt; callback.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/structured-outputs-vercel-ai-sdk-generateobject-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/structured-outputs-vercel-ai-sdk-generateobject-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>ai</category>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Node.js Runs TypeScript Now: Field Notes on Native Type Stripping</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Sun, 02 Aug 2026 06:06:37 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/nodejs-runs-typescript-now-field-notes-on-native-type-stripping-8b4</link>
      <guid>https://dev.to/ahmed_mahmoud360/nodejs-runs-typescript-now-field-notes-on-native-type-stripping-8b4</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Node.js executes TypeScript files directly — &lt;code&gt;node script.ts&lt;/code&gt; works with no loader, no ts-node, and no build step — by stripping type annotations at load time. Type stripping is on by default since Node.js 23.6 and ships in the 22.18 LTS release, but it only covers erasable syntax: I enforce that with TypeScript 5.8's &lt;code&gt;erasableSyntaxOnly&lt;/code&gt; flag, moved type checking to &lt;code&gt;tsc --noEmit&lt;/code&gt; in CI, and left my decorator-heavy NestJS services on their existing build.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;Node.js runs &lt;code&gt;.ts&lt;/code&gt; files natively by replacing type annotations with whitespace, a mechanism called type stripping. It is enabled by default since Node.js 23.6 and in the 22.18 LTS release; on Node 22.6–22.17 it sits behind &lt;code&gt;--experimental-strip-types&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Type stripping handles only erasable syntax. &lt;code&gt;enum&lt;/code&gt;, &lt;code&gt;namespace&lt;/code&gt; with runtime code, and constructor parameter properties need the separate &lt;code&gt;--experimental-transform-types&lt;/code&gt; flag.&lt;/li&gt;
&lt;li&gt;Node.js never type-checks and never reads &lt;code&gt;tsconfig.json&lt;/code&gt;. The type checker is still &lt;code&gt;tsc --noEmit&lt;/code&gt;, run in CI or a pre-commit hook.&lt;/li&gt;
&lt;li&gt;TypeScript 5.8's &lt;code&gt;erasableSyntaxOnly&lt;/code&gt; compiler option turns every non-erasable construct into a compile error, which guarantees a file Node.js can run.&lt;/li&gt;
&lt;li&gt;Relative imports must spell out the &lt;code&gt;.ts&lt;/code&gt; extension, and Node.js refuses to strip types inside &lt;code&gt;node_modules&lt;/code&gt; — published packages still ship JavaScript.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Can Node.js run TypeScript without a build step?
&lt;/h2&gt;

&lt;p&gt;Yes, for most application code. Node.js 22.6 introduced type stripping behind the &lt;code&gt;--experimental-strip-types&lt;/code&gt; flag, Node.js 23.6 turned it on by default, and the 22.18 release brought the default-on behavior to the LTS line. On Node.js 24 — the current LTS and my daily runtime — &lt;code&gt;node script.ts&lt;/code&gt; simply executes.&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;// hello.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;greet&lt;/span&gt; &lt;span class="o"&gt;=&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`Hello, &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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;greet&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 24&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;typescript&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 'strip'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mechanism matters. In strip mode Node.js replaces every type annotation with whitespace instead of compiling the file, so line and column numbers in stack traces match the source exactly, with no source maps involved. &lt;code&gt;process.features.typescript&lt;/code&gt; reports the active mode: &lt;code&gt;'strip'&lt;/code&gt;, &lt;code&gt;'transform'&lt;/code&gt;, or &lt;code&gt;false&lt;/code&gt;. The first things I moved over were the places a build step hurt most: one-off utilities in &lt;code&gt;scripts/&lt;/code&gt;, config files like &lt;code&gt;drizzle.config.ts&lt;/code&gt;, and cron jobs that previously dragged in ts-node just to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  What TypeScript syntax does type stripping not support?
&lt;/h2&gt;

&lt;p&gt;Erasable syntax is anything that can be deleted without changing runtime behavior: type annotations, &lt;code&gt;interface&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt; aliases, generics, &lt;code&gt;satisfies&lt;/code&gt;, and &lt;code&gt;as&lt;/code&gt; casts. Type stripping handles all of it. Four constructs generate JavaScript output and are therefore not erasable: &lt;code&gt;enum&lt;/code&gt;, &lt;code&gt;namespace&lt;/code&gt; with runtime values, constructor parameter properties (&lt;code&gt;constructor(private db: Database)&lt;/code&gt;), and &lt;code&gt;import x = require()&lt;/code&gt;. Files that use them need &lt;code&gt;--experimental-transform-types&lt;/code&gt;, which switches Node.js from stripping to a full SWC-based transform with source maps.&lt;/p&gt;

&lt;p&gt;I avoid the transform flag entirely and keep everything erasable. Enums were the only construct I actually had to migrate, and the replacement is arguably better TypeScript anyway:&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;// instead of: enum Role { Admin = 'admin', Editor = 'editor' }&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ROLES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;ROLES&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A const array plus a derived union type is erasable, serializes cleanly, and produces none of the surprising runtime objects enums do.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I guarantee my code stays erasable?
&lt;/h2&gt;

&lt;p&gt;TypeScript 5.8 added the &lt;code&gt;erasableSyntaxOnly&lt;/code&gt; compiler option, which turns every non-erasable construct into a compile error. With it enabled, code that passes the type checker is guaranteed to run under Node.js strip mode — the constraint is enforced by tooling instead of code review.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"erasableSyntaxOnly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"verbatimModuleSyntax"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"allowImportingTsExtensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"noEmit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"module"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nodenext"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three companions earn their place. &lt;code&gt;verbatimModuleSyntax&lt;/code&gt; forces type-only imports to be written as &lt;code&gt;import type&lt;/code&gt;, so stripping an import can never change the runtime module graph. &lt;code&gt;allowImportingTsExtensions&lt;/code&gt; permits the explicit &lt;code&gt;./util.ts&lt;/code&gt; specifiers Node.js requires — the runtime does not rewrite import paths. And one fact that reframes the whole setup: Node.js never reads &lt;code&gt;tsconfig.json&lt;/code&gt;. The config exists for the editor and for &lt;code&gt;tsc&lt;/code&gt;; the runtime ignores it in both strip and transform modes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does type checking happen if Node strips types?
&lt;/h2&gt;

&lt;p&gt;Nowhere at runtime. Node.js discards annotations without validating them, which is the same trade-off esbuild-based runners like tsx made years ago — the ecosystem already proved it workable. My setup: &lt;code&gt;tsc --noEmit&lt;/code&gt; runs as a CI gate and in a pre-commit hook, and the editor surfaces errors live through the TypeScript language server. The honest failure mode: a file with a type error still executes, so the gate — not the runtime — is what keeps it from shipping. Type stripping makes broken types quieter, not safer, which is exactly why the gate stays mandatory.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did I replace, and what still needs tsx or a build?
&lt;/h2&gt;

&lt;p&gt;Native execution replaced ts-node and tsx for me in every script-shaped context, but not everywhere. The comparison I actually use:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Runner&lt;/th&gt;
&lt;th&gt;Type checks?&lt;/th&gt;
&lt;th&gt;Non-erasable syntax&lt;/th&gt;
&lt;th&gt;When I reach for it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Node.js type stripping&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No (strip mode)&lt;/td&gt;
&lt;td&gt;Scripts, configs, cron jobs on Node 22.18+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tsx&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, plus JSX and path aliases&lt;/td&gt;
&lt;td&gt;Older Node versions or tsconfig path aliases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ts-node&lt;/td&gt;
&lt;td&gt;Optional&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Legacy projects that already depend on it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tsc build&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Published libraries and decorator-heavy apps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The dependency count is the underrated win. A repo whose scripts run on bare Node.js needs no loader in devDependencies and no &lt;code&gt;--import&lt;/code&gt; wiring, and &lt;code&gt;node --watch script.ts&lt;/code&gt; covers the dev loop that tsx's watch mode used to handle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which projects should keep their build step?
&lt;/h2&gt;

&lt;p&gt;My NestJS services kept theirs. NestJS dependency injection relies on &lt;code&gt;emitDecoratorMetadata&lt;/code&gt;, and Node.js emits no decorator metadata in either strip or transform mode, so constructor injection loses the type information it resolves providers with — native execution is not an option there. The other hard boundaries: JSX/TSX files are out of scope, Node.js does not resolve &lt;code&gt;tsconfig&lt;/code&gt; path aliases, and type stripping deliberately skips &lt;code&gt;node_modules&lt;/code&gt;, so anything published to npm must ship compiled JavaScript. Libraries need &lt;code&gt;tsc&lt;/code&gt; for &lt;code&gt;.d.ts&lt;/code&gt; output regardless.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need ts-node or tsx?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Not for erasable-syntax scripts on Node.js 22.18 or newer. tsx still earns its keep for JSX, tsconfig path aliases, and older Node versions; ts-node I no longer install at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does Node.js type-check the TypeScript it runs?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. Type stripping discards annotations without validating them. Run &lt;code&gt;tsc --noEmit&lt;/code&gt; in CI or a pre-commit hook to catch type errors before they ship.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does Node.js read tsconfig.json?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. The runtime ignores it in both strip and transform modes. tsconfig.json configures the editor and tsc only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I use enums with native TypeScript execution?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Only behind &lt;code&gt;--experimental-transform-types&lt;/code&gt;. A const array or const object with a derived union type is erasable and behaves more predictably — I migrated my enums instead of enabling the flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do TypeScript files inside node_modules work?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. Node.js deliberately refuses to strip types in node_modules, so published packages must ship compiled JavaScript with declaration files.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/nodejs-native-typescript-type-stripping-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/nodejs-native-typescript-type-stripping-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>node</category>
      <category>typescript</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>AbortController Beyond fetch: The Cancellation Patterns I Use in Every React App</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Sat, 01 Aug 2026 06:07:20 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/abortcontroller-beyond-fetch-the-cancellation-patterns-i-use-in-every-react-app-3i3n</link>
      <guid>https://dev.to/ahmed_mahmoud360/abortcontroller-beyond-fetch-the-cancellation-patterns-i-use-in-every-react-app-3i3n</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; AbortController is the web platform's single cancellation primitive: one controller produces one AbortSignal, and that signal plugs into fetch, addEventListener, and most modern data libraries. Four patterns — abort-on-cleanup in useEffect, &lt;code&gt;AbortSignal.timeout()&lt;/code&gt; for deadlines, &lt;code&gt;AbortSignal.any()&lt;/code&gt; to merge cancel sources, and the &lt;code&gt;signal&lt;/code&gt; option for bulk listener removal — eliminated a whole class of race conditions in my React apps.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;AbortController&lt;/code&gt; creates an &lt;code&gt;AbortSignal&lt;/code&gt;; calling &lt;code&gt;controller.abort(reason)&lt;/code&gt; flips &lt;code&gt;signal.aborted&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; and rejects any in-flight &lt;code&gt;fetch&lt;/code&gt; that was given that signal.&lt;/li&gt;
&lt;li&gt;An AbortController is one-shot. Once aborted it stays aborted, so every new request needs a fresh controller.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AbortSignal.timeout(ms)&lt;/code&gt; returns a signal that aborts with a &lt;code&gt;TimeoutError&lt;/code&gt; DOMException — it replaces hand-rolled &lt;code&gt;setTimeout&lt;/code&gt; + &lt;code&gt;abort()&lt;/code&gt; + &lt;code&gt;clearTimeout&lt;/code&gt; wiring.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AbortSignal.any([a, b])&lt;/code&gt; merges cancellation sources: the returned signal aborts as soon as the first input signal aborts.&lt;/li&gt;
&lt;li&gt;Aborting a request cancels the response, not the server-side work. Never use abort to undo a write; abort reads freely.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is AbortController and how does the signal actually work?
&lt;/h2&gt;

&lt;p&gt;AbortController is a built-in browser and Node.js class that produces a single AbortSignal, and that signal is the web platform's standard way to say &lt;em&gt;stop this work&lt;/em&gt;. I create a controller, hand its &lt;code&gt;signal&lt;/code&gt; to any API that accepts one, and call &lt;code&gt;controller.abort(reason)&lt;/code&gt; when the work is no longer needed. Every consumer of the signal reacts at once: an in-flight &lt;code&gt;fetch&lt;/code&gt; rejects, listeners registered with the signal are removed, and my own code can check &lt;code&gt;signal.aborted&lt;/code&gt; or call &lt;code&gt;signal.throwIfAborted()&lt;/code&gt; inside a loop.&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;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="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// false&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="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;user navigated away&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aborted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&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="c1"&gt;// Error: user navigated away&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The part that trips people up: a controller is one-shot. There is no reset. Once &lt;code&gt;abort()&lt;/code&gt; has been called, that signal is aborted forever, and any fetch started with it fails immediately. Every new request gets a new AbortController.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I cancel a stale fetch in a React useEffect?
&lt;/h2&gt;

&lt;p&gt;The classic race condition: a search box fires one request per keystroke, and a slow early response resolves after a fast later one, overwriting fresh results with stale data. The fix is to abort the previous request in the effect cleanup, so only the latest request can ever reach setState.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="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="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/search?q=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&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="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;setResults&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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="nf"&gt;setError&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;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;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;React runs the cleanup before re-running the effect, so fast typing aborts each stale request the moment the next one starts. The same cleanup fires on unmount, which kills the setState-after-unmount class of warnings at the source instead of guarding with an &lt;code&gt;isMounted&lt;/code&gt; flag. TanStack Query does this automatically — the &lt;code&gt;queryFn&lt;/code&gt; receives &lt;code&gt;{ signal }&lt;/code&gt;, and passing it through to &lt;code&gt;fetch&lt;/code&gt; is all it takes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does AbortSignal.timeout() replace?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AbortSignal.timeout(ms)&lt;/code&gt; returns a signal that aborts automatically after the given number of milliseconds. It replaces the pattern most codebases hand-rolled for years: create a controller, call &lt;code&gt;setTimeout(() =&amp;gt; controller.abort(), ms)&lt;/code&gt;, then remember to &lt;code&gt;clearTimeout&lt;/code&gt; on success. Three lines of lifecycle management collapse into one expression.&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;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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/report&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;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8000&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 matter. First, a timeout signal aborts with a &lt;code&gt;TimeoutError&lt;/code&gt; DOMException, while a manual &lt;code&gt;abort()&lt;/code&gt; produces an &lt;code&gt;AbortError&lt;/code&gt; — error handling can tell a deadline from a deliberate cancel. Second, &lt;code&gt;AbortSignal.timeout()&lt;/code&gt; works in all modern browsers and in Node.js 18+, so the same code runs in route handlers and server-side jobs too.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I combine a timeout with a user cancel button?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AbortSignal.any(signals)&lt;/code&gt; takes an array of signals and returns one that aborts as soon as any input aborts — the cancellation equivalent of &lt;code&gt;Promise.race()&lt;/code&gt;. My most common use is a long AI generation request that should stop when the user clicks cancel or when a hard deadline passes, whichever comes 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="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="c1"&gt;// wired to a cancel button&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/generate&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;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The merged signal's &lt;code&gt;reason&lt;/code&gt; comes from whichever source fired first, so &lt;code&gt;err.name&lt;/code&gt; still says whether the user cancelled (&lt;code&gt;AbortError&lt;/code&gt;) or the deadline hit (&lt;code&gt;TimeoutError&lt;/code&gt;). &lt;code&gt;AbortSignal.any()&lt;/code&gt; is newer than the rest of the API — Chrome 116+, Safari 17.4+, Firefox 124+, Node.js 20.3+ — but that covers evergreen browsers, and I use it without a polyfill.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can one signal remove many event listeners at once?
&lt;/h2&gt;

&lt;p&gt;Yes. &lt;code&gt;addEventListener&lt;/code&gt; accepts a &lt;code&gt;signal&lt;/code&gt; option, and aborting that signal removes every listener registered with it. This is my default for drag interactions, keyboard-shortcut scopes, and any widget that attaches listeners to &lt;code&gt;window&lt;/code&gt; or &lt;code&gt;document&lt;/code&gt;: no stored function references, no matching &lt;code&gt;removeEventListener&lt;/code&gt; calls, one &lt;code&gt;abort()&lt;/code&gt; tears everything down.&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;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="nb"&gt;window&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;resize&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onResize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&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;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;scroll&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onScroll&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;element&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;pointermove&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onMove&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// teardown — all three listeners removed at once&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In React this pairs cleanly with &lt;code&gt;useEffect&lt;/code&gt;: register every listener with one signal, return &lt;code&gt;() =&amp;gt; controller.abort()&lt;/code&gt; as the cleanup, and teardown can never drift out of sync with setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I tell a cancellation from a real failure?
&lt;/h2&gt;

&lt;p&gt;An aborted fetch rejects, so cancellations land in the same &lt;code&gt;catch&lt;/code&gt; block as genuine network errors — and reporting them blindly fills the error tracker with noise every time a user navigates away mid-request. The discriminator is the error's &lt;code&gt;name&lt;/code&gt; property.&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;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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="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;instanceof&lt;/span&gt; &lt;span class="nx"&gt;DOMException&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;err&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;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// expected: we cancelled&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;instanceof&lt;/span&gt; &lt;span class="nx"&gt;DOMException&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;err&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;TimeoutError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;reportTimeout&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;// deadline hit — worth counting&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="c1"&gt;// real failure&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Passing a custom value to &lt;code&gt;abort(reason)&lt;/code&gt; makes that value the rejection instead of the default DOMException. That is useful when the abort should carry context, but it also means an &lt;code&gt;AbortError&lt;/code&gt; name check will not match — pick one convention per codebase and stick to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should I not abort a request?
&lt;/h2&gt;

&lt;p&gt;Aborting cancels the response, not the server-side work. By the time &lt;code&gt;abort()&lt;/code&gt; runs on a POST, the server may already have committed the write — closing the connection rolls nothing back. My rule: abort reads freely and aggressively; let writes finish. When a user genuinely needs to cancel a write, an idempotency key plus an explicit compensating request is the honest design; an aborted POST that maybe-committed is not. The rule has a server-side mirror: in a Next.js route handler, &lt;code&gt;request.signal&lt;/code&gt; aborts when the client disconnects, and long streaming work should check it so the server stops paying for output nobody is reading.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does aborting a fetch cancel the request on the server?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. &lt;code&gt;abort()&lt;/code&gt; closes the client connection, but work the server already started may run to completion. Treat abort as client-side cleanup, and design writes with idempotency keys if cancellation matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I reuse an AbortController after calling abort()?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. A controller is one-shot — once aborted, its signal stays aborted and any fetch given that signal rejects immediately. Create a new controller per request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What error does an aborted fetch throw?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; A DOMException named &lt;code&gt;AbortError&lt;/code&gt; for a manual &lt;code&gt;abort()&lt;/code&gt;, or &lt;code&gt;TimeoutError&lt;/code&gt; when the signal came from &lt;code&gt;AbortSignal.timeout()&lt;/code&gt;. A custom reason passed to &lt;code&gt;abort(reason)&lt;/code&gt; becomes the rejection value instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does TanStack Query use AbortController under the hood?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. The &lt;code&gt;queryFn&lt;/code&gt; receives an object containing &lt;code&gt;signal&lt;/code&gt;; pass it to &lt;code&gt;fetch&lt;/code&gt; and superseded or unmounted queries are aborted automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is AbortSignal.any() safe to use without a polyfill?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; For evergreen targets in 2026, yes: it shipped in Chrome 116, Safari 17.4, Firefox 124, and Node.js 20.3. For older browsers, feature-detect and fall back to a single controller.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/abortcontroller-cancellation-patterns-react-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/abortcontroller-cancellation-patterns-react-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>javascript</category>
      <category>react</category>
      <category>webdev</category>
      <category>frontend</category>
    </item>
    <item>
      <title>One API Key for Every Model: Field Notes on the Vercel AI Gateway</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Fri, 31 Jul 2026 14:13:21 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/one-api-key-for-every-model-field-notes-on-the-vercel-ai-gateway-4anm</link>
      <guid>https://dev.to/ahmed_mahmoud360/one-api-key-for-every-model-field-notes-on-the-vercel-ai-gateway-4anm</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; The Vercel AI Gateway is a single endpoint that routes AI SDK calls to any model provider through a plain &lt;code&gt;"provider/model"&lt;/code&gt; string. I removed &lt;code&gt;@ai-sdk/anthropic&lt;/code&gt;, &lt;code&gt;@ai-sdk/openai&lt;/code&gt;, and three separate provider API keys, and now switch models by editing one string instead of swapping SDK packages.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;Vercel AI Gateway&lt;/strong&gt; is a unified API, GA since August 2025, that sits between your app and multiple LLM providers so one credential and one code path reach any model.&lt;/li&gt;
&lt;li&gt;With the AI SDK you select a model by a &lt;code&gt;"provider/model"&lt;/code&gt; string — &lt;code&gt;'anthropic/claude-sonnet-5'&lt;/code&gt;, &lt;code&gt;'openai/gpt-5'&lt;/code&gt; — instead of importing a provider-specific package like &lt;code&gt;@ai-sdk/anthropic&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model fallbacks&lt;/strong&gt; let you declare an ordered list of models; if the primary is rate-limited or erroring, the gateway retries the next on the same request without your code branching.&lt;/li&gt;
&lt;li&gt;The gateway adds &lt;strong&gt;observability&lt;/strong&gt; (per-request cost, latency, tokens) and supports &lt;strong&gt;zero data retention&lt;/strong&gt;, so switching providers no longer means re-instrumenting logging.&lt;/li&gt;
&lt;li&gt;The gateway replaces per-provider SDK wiring and key management — not the AI SDK itself; you still call &lt;code&gt;generateText&lt;/code&gt;, &lt;code&gt;streamText&lt;/code&gt;, and the same tool-calling API.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is the Vercel AI Gateway and what problem does it solve?
&lt;/h2&gt;

&lt;p&gt;The Vercel AI Gateway is a hosted proxy that exposes a single OpenAI-compatible endpoint and forwards requests to whichever provider a model string names. Instead of holding an Anthropic key, an OpenAI key, and a Google key — each with its own SDK and rate-limit behavior — you hold one gateway credential and name the model inline. The problem it solves is provider lock-in at the code level: when a call is &lt;code&gt;anthropic('claude-sonnet-5')&lt;/code&gt;, trying a different provider means a new import, a new client, and often a new logging shape. When the call is the string &lt;code&gt;'anthropic/claude-sonnet-5'&lt;/code&gt;, trying &lt;code&gt;'openai/gpt-5'&lt;/code&gt; is a one-line diff.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I switch from a provider SDK to the gateway?
&lt;/h2&gt;

&lt;p&gt;Drop the provider import and pass a &lt;code&gt;"provider/model"&lt;/code&gt; string to the AI SDK's &lt;code&gt;model&lt;/code&gt; field. The AI SDK resolves any bare string through the gateway by default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: provider SDK + provider-specific key&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;anthropic&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@ai-sdk/anthropic&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;generateText&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;claude-sonnet-5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Summarize this support ticket in two sentences.&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After: gateway string routing, one AI_GATEWAY_API_KEY&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;generateText&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anthropic/claude-sonnet-5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Summarize this support ticket in two sentences.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything downstream — &lt;code&gt;streamText&lt;/code&gt;, &lt;code&gt;generateObject&lt;/code&gt;, tool calling — stays identical. The model becomes configuration, not code.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should I use model fallbacks?
&lt;/h2&gt;

&lt;p&gt;Use fallbacks when a request must succeed even if your preferred provider is rate-limited or down. The gateway tries the primary, and on a provider error retries the next model transparently within the same 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;generateText&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="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;gateway&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@ai-sdk/gateway&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;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;gateway&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anthropic/claude-sonnet-5&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;fallbacks&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;openai/gpt-5&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;google/gemini-2.5-pro&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;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Draft a release note from this git diff.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I do &lt;em&gt;not&lt;/em&gt; use fallbacks on background jobs where I want deterministic behavior — a nightly batch should fail loudly and retry the same model, not silently answer with a different one whose output I never evaluated. Fallbacks trade consistency for availability; spend that trade only where availability matters more.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the gateway replace, and what stays?
&lt;/h2&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;Before (per-provider SDK)&lt;/th&gt;
&lt;th&gt;With the AI Gateway&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model selection&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;anthropic('...')&lt;/code&gt;, &lt;code&gt;openai('...')&lt;/code&gt; imports&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'provider/model'&lt;/code&gt; string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credentials&lt;/td&gt;
&lt;td&gt;One API key per provider&lt;/td&gt;
&lt;td&gt;One gateway key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Switching provider&lt;/td&gt;
&lt;td&gt;New import + client + key&lt;/td&gt;
&lt;td&gt;Edit the model string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fallback on outage&lt;/td&gt;
&lt;td&gt;Hand-written try/catch&lt;/td&gt;
&lt;td&gt;Ordered &lt;code&gt;fallbacks&lt;/code&gt; list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost + latency&lt;/td&gt;
&lt;td&gt;Per-provider dashboards&lt;/td&gt;
&lt;td&gt;Unified per-request metrics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SDK call shape&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;generateText&lt;/code&gt;/&lt;code&gt;streamText&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What about cost, data retention, and observability?
&lt;/h2&gt;

&lt;p&gt;The gateway reports cost, latency, and token counts per request in one place, so comparing two models on the same prompt no longer means stitching together two providers' billing pages. It also supports a &lt;strong&gt;zero data retention&lt;/strong&gt; mode, meaning request and response bodies are not stored by the gateway — important when the prompt carries customer data and you need a clean data-flow answer for a security review. The practical win is measurement: when model choice is a string and metrics are unified, a real comparison — same prompt, three models, look at cost and latency — is a config change plus a dashboard read, not an engineering project.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd watch before going all-in
&lt;/h2&gt;

&lt;p&gt;One indirection layer means one more dependency in the request path — if the gateway is down, every model call is down, so I still keep the fallback list plus sane timeouts. And a bare string is only provider-agnostic until you rely on a provider-specific feature (a tool format, a cache-control header); then you're back to provider options, just passed through the gateway. Neither is a dealbreaker, but both are worth knowing before you delete the provider packages like I did.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need the AI SDK if I use the Vercel AI Gateway?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. The gateway routes model requests; the AI SDK is still what you call in code (&lt;code&gt;generateText&lt;/code&gt;, &lt;code&gt;streamText&lt;/code&gt;, &lt;code&gt;generateObject&lt;/code&gt;). It replaces provider-specific packages like &lt;code&gt;@ai-sdk/anthropic&lt;/code&gt;, not the core &lt;code&gt;ai&lt;/code&gt; package.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I select a model through the gateway?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Pass a &lt;code&gt;"provider/model"&lt;/code&gt; string to the &lt;code&gt;model&lt;/code&gt; field, e.g. &lt;code&gt;'anthropic/claude-sonnet-5'&lt;/code&gt; or &lt;code&gt;'openai/gpt-5'&lt;/code&gt;. Bare model strings resolve through the gateway by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; What happens if my primary model is rate-limited?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; With an ordered &lt;code&gt;fallbacks&lt;/code&gt; list, the gateway retries the next model on the same request. Without fallbacks, the call returns the provider error for you to handle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does the gateway store my prompts?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; It supports a zero data retention mode where request and response bodies are not retained. Verify the setting for your account before sending sensitive data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Should I use provider-specific SDKs at all in 2026?&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Default to gateway string routing for portability. Reach for a direct provider SDK only when you need a provider-specific capability the gateway doesn't surface.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/vercel-ai-gateway-provider-routing-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/vercel-ai-gateway-provider-routing-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>ai</category>
      <category>vercel</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>WebSockets on Vercel Functions: Real-Time Without a Separate Server</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Mon, 27 Jul 2026 22:34:52 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/websockets-on-vercel-functions-real-time-without-a-separate-server-f8</link>
      <guid>https://dev.to/ahmed_mahmoud360/websockets-on-vercel-functions-real-time-without-a-separate-server-f8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Vercel Functions can now hold WebSocket connections open directly on Fluid Compute, the same runtime that already serves my regular API routes — I stopped standing up a dedicated Socket.IO server and a Redis pub/sub layer just to push live updates to a browser tab.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;Vercel Functions support WebSockets natively through &lt;code&gt;experimental_upgradeWebSocket()&lt;/code&gt; from &lt;code&gt;@vercel/functions&lt;/code&gt;, running on Fluid Compute — no standalone WebSocket server or third-party realtime service required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fluid Compute&lt;/strong&gt; reuses a warm function instance across concurrent requests instead of spinning up one instance per request; that's what makes holding a long-lived socket open practical on a platform historically built around short request/response cycles.&lt;/li&gt;
&lt;li&gt;Standard Node.js libraries like &lt;code&gt;ws&lt;/code&gt; work inside a Vercel Function unchanged — I didn't rewrite client or server socket logic to move this off a dedicated server.&lt;/li&gt;
&lt;li&gt;WebSockets still win over Server-Sent Events for anything bidirectional: chat input, collaborative cursors, live multiplayer state. SSE stays the simpler choice for one-way streaming.&lt;/li&gt;
&lt;li&gt;Managed services like Pusher or Ably still earn their place at large fan-out scale or when you need built-in presence and room primitives; for a single app's realtime feature, self-hosting on Vercel Functions is now a realistic default.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Can a Vercel Function actually keep a WebSocket connection open?
&lt;/h2&gt;

&lt;p&gt;Yes, as long as it runs on Fluid Compute, the default runtime for Vercel Functions. The old serverless mental model — one isolated container per invocation, torn down the moment the response finishes — never had a slot for a connection that stays open for minutes or hours.&lt;/p&gt;

&lt;p&gt;Fluid Compute changes the unit of work: instances are reused across concurrent requests, so a function can keep a socket alive across that reused instance instead of dying the second it returns a response. That's also why the platform supports graceful shutdown and request cancellation on the same runtime — a function mid-stream when a deploy rolls out gets a chance to close connections cleanly instead of being killed mid-write.&lt;/p&gt;

&lt;p&gt;For a chat feature or a live dashboard, that's the difference between a socket that silently drops on every deploy and one that reconnects predictably.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I upgrade a Vercel Function to a WebSocket connection?
&lt;/h2&gt;

&lt;p&gt;I call &lt;code&gt;experimental_upgradeWebSocket()&lt;/code&gt; from &lt;code&gt;@vercel/functions&lt;/code&gt; inside a route handler, the same way I'd upgrade an HTTP connection on any Node.js server:&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/live/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;experimental_upgradeWebSocket&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;experimental_upgradeWebSocket&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="nf"&gt;onOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;connected&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;onMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ack&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nf"&gt;onClose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// clean up per-connection state&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;On the client it's a plain &lt;code&gt;WebSocket&lt;/code&gt; — nothing Vercel-specific to install:&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;socket&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;WebSocket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wss://your-app.vercel.app/api/live&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmessage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;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;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;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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I'm already using &lt;code&gt;ws&lt;/code&gt; for connection pooling, rooms, or heartbeat handling, that code carries over — the upgrade point is the only thing that's Vercel-specific.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should I reach for WebSockets instead of SSE or polling?
&lt;/h2&gt;

&lt;p&gt;The deciding question is direction: does the client need to send frequent messages back, not just receive them? If yes, WebSockets are the right tool. If the client only ever listens, Server-Sent Events are simpler to operate and debug.&lt;/p&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;WebSocket&lt;/th&gt;
&lt;th&gt;SSE&lt;/th&gt;
&lt;th&gt;Polling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direction&lt;/td&gt;
&lt;td&gt;Bidirectional&lt;/td&gt;
&lt;td&gt;Server → client only&lt;/td&gt;
&lt;td&gt;Client-initiated, repeated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protocol&lt;/td&gt;
&lt;td&gt;Own upgrade (&lt;code&gt;ws://&lt;/code&gt;, &lt;code&gt;wss://&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Plain HTTP, &lt;code&gt;text/event-stream&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Plain HTTP requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reconnection&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Built into &lt;code&gt;EventSource&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Good fit&lt;/td&gt;
&lt;td&gt;Chat, collaborative editing, multiplayer cursors&lt;/td&gt;
&lt;td&gt;Token streaming, notifications, live logs&lt;/td&gt;
&lt;td&gt;Low-frequency status checks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infra on Vercel&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;experimental_upgradeWebSocket()&lt;/code&gt; + Fluid Compute&lt;/td&gt;
&lt;td&gt;Any Node.js runtime&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I'd already covered SSE for token streaming in an earlier post — that's still the right call for one-way AI output. WebSockets are for the features where the browser talks back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do I still need Pusher, Ably, or a standalone WebSocket server?
&lt;/h2&gt;

&lt;p&gt;Sometimes, but not by default anymore. Managed realtime platforms like Pusher and Ably handle two things that are genuinely hard to build yourself: fan-out across thousands of concurrent connections, and presence/room primitives as an out-of-the-box API. If your feature is at that scale, a managed service is still the pragmatic choice.&lt;/p&gt;

&lt;p&gt;What changed is the baseline. Before, any WebSocket on Vercel meant running a separate always-on server elsewhere just to hold the socket. Now a single-feature realtime need can live entirely inside the same Vercel Function that serves the rest of your API. One deploy target, one auth model, one set of environment variables.&lt;/p&gt;

&lt;h2&gt;
  
  
  What connection limits and reconnection patterns do I need to handle myself?
&lt;/h2&gt;

&lt;p&gt;WebSockets don't get automatic reconnection the way &lt;code&gt;EventSource&lt;/code&gt; does for SSE — that logic is on you.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exponential backoff on reconnect&lt;/strong&gt; — retry with increasing delay (1s, 2s, 4s, capped around 30s) instead of hammering the endpoint immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heartbeat pings&lt;/strong&gt; — send a ping on an interval, expect a pong back; if none arrives, treat the connection as dead and reconnect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idle timeouts are real&lt;/strong&gt; — a connection with no traffic can be dropped by intermediate proxies; the heartbeat above doubles as keep-alive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resync state on reconnect&lt;/strong&gt; — design the protocol so the server can answer "catch me up from X" rather than assuming the client saw every message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is Vercel-specific — it's the same discipline any WebSocket client needs — but it matters more here because there's no managed service hiding it from you.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I need the Edge runtime to use WebSockets on Vercel?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. WebSocket support runs on Fluid Compute with the standard Node.js runtime — you don't need &lt;code&gt;runtime = 'edge'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can I use the &lt;code&gt;ws&lt;/code&gt; npm package directly instead of &lt;code&gt;experimental_upgradeWebSocket()&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; &lt;code&gt;experimental_upgradeWebSocket()&lt;/code&gt; performs the HTTP-to-WebSocket upgrade inside a Vercel Function; once open, existing socket-handling code, including logic built around &lt;code&gt;ws&lt;/code&gt;, carries over largely unchanged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Is this the same as SSE for AI streaming?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. SSE is one-way and remains the right choice for streaming AI tokens. WebSockets are bidirectional and fit features where the client also sends frequent messages back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do WebSocket connections survive a new deployment?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Fluid Compute supports graceful shutdown and request cancellation, giving in-flight connections a chance to close cleanly during a deploy — but your client should still implement reconnect logic for the brief rollover window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I still need Redis for multi-instance fan-out?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; If your app runs on a single reused Fluid Compute instance, in-memory broadcast is enough. Once traffic spans multiple instances, a shared layer like Redis pub/sub is still needed to fan messages out across them.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/websockets-vercel-functions-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/websockets-vercel-functions-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>websockets</category>
      <category>vercel</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Next.js Middleware in 2026: Auth Guards, A/B Tests, and What Belongs at the Edge</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Sun, 26 Jul 2026 21:03:49 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/nextjs-middleware-in-2026-auth-guards-ab-tests-and-what-belongs-at-the-edge-10f5</link>
      <guid>https://dev.to/ahmed_mahmoud360/nextjs-middleware-in-2026-auth-guards-ab-tests-and-what-belongs-at-the-edge-10f5</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Next.js Middleware (middleware.ts at the project root) runs before every matched request — before cache, before rendering, before the route. That position makes it right for auth redirects, A/B cookie bucketing, and locale detection. Wrong for database queries and heavy imports. In 2026, Middleware on Vercel runs on Fluid Compute (standard Node.js), so the constraint is latency budget, not API availability.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Middleware runs before every matched request&lt;/strong&gt; — before cache, rendering, or route handler — the right layer for auth, locale, and A/B bucketing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Middleware can read requests, set cookies, redirect, rewrite, or return early&lt;/strong&gt; — without the route running. DB queries and large packages add latency to every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On Vercel in 2026, Middleware runs on Fluid Compute (standard Node.js).&lt;/strong&gt; The constraint is latency: every added millisecond is paid on every matched request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;matcher&lt;/code&gt; to scope Middleware&lt;/strong&gt; to only the routes that need it; without it Middleware runs on every static asset request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth in Middleware = verifying a self-contained JWT&lt;/strong&gt; without a DB call. Full session validation belongs in the route.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I spent a long time only using Middleware for locale redirects. After shipping auth-protected routes and an A/B test, the full shape became clear.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Next.js Middleware and where does it run?
&lt;/h2&gt;

&lt;p&gt;Middleware is exported from &lt;code&gt;middleware.ts&lt;/code&gt; at the project root. It intercepts matched requests before route resolution, cache lookup, and Server Component execution. Returns one of four types: pass through (&lt;code&gt;NextResponse.next()&lt;/code&gt;), redirect, rewrite (serve different content while keeping original URL in address bar), or a direct response.&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;function&lt;/span&gt; &lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/((?!_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="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;matcher&lt;/code&gt;, Middleware runs on every request including static files. On Vercel in 2026, Middleware runs on Fluid Compute — standard Node.js. Aim for under 10 ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I write an auth guard?
&lt;/h2&gt;

&lt;p&gt;Read a JWT from a cookie, verify locally with &lt;code&gt;jose&lt;/code&gt; (no DB call), redirect to &lt;code&gt;/login&lt;/code&gt; if missing or invalid. Delete the cookie on invalid tokens to prevent a redirect loop.&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;jwtVerify&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jose&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;SECRET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;JWT_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;session&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;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;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;jwtVerify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;session&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// prevent redirect loop&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/dashboard/:path*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redirect target must be a &lt;code&gt;URL&lt;/code&gt; object, not a bare string.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I run A/B tests?
&lt;/h2&gt;

&lt;p&gt;Check for a bucket cookie, assign one if absent, rewrite to the variant URL. The rewrite keeps the address bar showing the original URL — share links always go to control.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&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="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;/pricing&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;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ab-pricing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;variant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;??&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;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;control&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;variant-b&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&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;pathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`/_experiments/pricing/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;variant&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;res&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;rewrite&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;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;existing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&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;ab-pricing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maxAge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;httpOnly&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;return&lt;/span&gt; &lt;span class="nx"&gt;res&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;Variants live at &lt;code&gt;app/_experiments/pricing/control/&lt;/code&gt; and &lt;code&gt;app/_experiments/pricing/variant-b/&lt;/code&gt; — the &lt;code&gt;_&lt;/code&gt; prefix makes them private routes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What can Middleware read and write?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read cookies&lt;/td&gt;
&lt;td&gt;&lt;code&gt;request.cookies.get('name')&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Typed helpers built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set cookies&lt;/td&gt;
&lt;td&gt;&lt;code&gt;response.cookies.set(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;On any NextResponse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Forward headers&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NextResponse.next({ request: { headers } })&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pass locale, user ID downstream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redirect (URL changes)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NextResponse.redirect(url)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full URL object required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rewrite (URL stays)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NextResponse.rewrite(url)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A/B tests, path aliases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Early response&lt;/td&gt;
&lt;td&gt;&lt;code&gt;new NextResponse(body, { status })&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rate limit, maintenance mode&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What should NOT go in Middleware?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Database queries&lt;/strong&gt; — every matched request pays the round trip before the route runs; use a self-verifiable JWT instead. &lt;strong&gt;Heavy npm imports&lt;/strong&gt; — increase cold-start time; keep &lt;code&gt;middleware.ts&lt;/code&gt; to &lt;code&gt;jose&lt;/code&gt; and nothing else avoidable. &lt;strong&gt;Network-dependent rate limiting&lt;/strong&gt; — a Redis timeout becomes a timeout on every request; put rate limiting in a Route Handler.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q: Does Middleware run on static file requests?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Only on routes matching &lt;code&gt;config.matcher&lt;/code&gt;. Without it, runs on everything including &lt;code&gt;_next/static&lt;/code&gt;. Use the standard negative lookahead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What's the difference between &lt;code&gt;redirect&lt;/code&gt; and &lt;code&gt;rewrite&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; &lt;code&gt;redirect&lt;/code&gt; sends 307/308 and the browser navigates — address bar changes. &lt;code&gt;rewrite&lt;/code&gt; serves different content while keeping the original URL. Auth → redirect; A/B tests → rewrite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can Middleware read the POST body?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No — buffering breaks streaming and prevents the route from reading it. Decisions on cookies, headers, and URL only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Is Middleware still on the Edge Runtime in 2026?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Not on Vercel — it runs on Fluid Compute (standard Node.js). Constraint is latency budget, not API availability.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/nextjs-middleware-patterns-auth-ab-testing-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/nextjs-middleware-patterns-auth-ab-testing-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>performance</category>
    </item>
    <item>
      <title>Shipping a Bilingual Next.js App in English and Arabic: Routing, RTL, and What Crawlers Actually See</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Sat, 25 Jul 2026 13:03:14 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/shipping-a-bilingual-nextjs-app-in-english-and-arabic-routing-rtl-and-what-crawlers-actually-see-517i</link>
      <guid>https://dev.to/ahmed_mahmoud360/shipping-a-bilingual-nextjs-app-in-english-and-arabic-routing-rtl-and-what-crawlers-actually-see-517i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; A bilingual Next.js site needs three things done right: one URL per language (a &lt;code&gt;/en&lt;/code&gt; and &lt;code&gt;/ar&lt;/code&gt; path prefix, not a cookie), a layout that flips through CSS logical properties instead of hand-patched left/right overrides, and &lt;code&gt;hreflang&lt;/code&gt; metadata that tells search engines the two URLs are translations of each other. I learned the first one the hard way — a cookie-driven language switcher meant crawlers only ever indexed the English half of the site.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Serve each language on its own URL.&lt;/strong&gt; Path prefixes such as &lt;code&gt;/en/services&lt;/code&gt; and &lt;code&gt;/ar/services&lt;/code&gt; are the multilingual structure Google's documentation recommends; content that switches per cookie on a single URL gets exactly one language indexed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search engine crawlers do not persist cookies&lt;/strong&gt;, and Googlebot crawls mostly from US IP addresses without varying its &lt;code&gt;Accept-Language&lt;/code&gt; header — a cookie-based language toggle is invisible to them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dir="rtl"&lt;/code&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; plus CSS logical properties flip a layout automatically.&lt;/strong&gt; Tailwind's &lt;code&gt;ms-*&lt;/code&gt;, &lt;code&gt;me-*&lt;/code&gt;, &lt;code&gt;ps-*&lt;/code&gt;, &lt;code&gt;pe-*&lt;/code&gt;, and &lt;code&gt;text-start&lt;/code&gt; utilities map to &lt;code&gt;margin-inline-start&lt;/code&gt; and friends.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load Arabic fonts per locale with &lt;code&gt;next/font&lt;/code&gt;&lt;/strong&gt; — for example &lt;code&gt;IBM_Plex_Sans_Arabic&lt;/code&gt; with the &lt;code&gt;arabic&lt;/code&gt; subset — so English visitors never download the Arabic font and vice versa.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hreflang&lt;/code&gt; lives in &lt;code&gt;generateMetadata&lt;/code&gt;&lt;/strong&gt; through &lt;code&gt;alternates.languages&lt;/code&gt;, must be reciprocal between the two locales, and needs an &lt;code&gt;x-default&lt;/code&gt; pointing at the fallback URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of what I build serves users in both English and Arabic. The first bilingual site I shipped used a cookie-driven switcher: one URL, and the server rendered whichever language the cookie asked for. It felt elegant until I checked what search engines had indexed — English only, on every page. These field notes cover the move to URL-prefix routing in the Next.js App Router, treating RTL as a first-class layout mode instead of a patch, and the metadata that makes both languages visible to crawlers and answer engines.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should I structure locale routing in the Next.js App Router?
&lt;/h2&gt;

&lt;p&gt;Put the locale in the URL as the first path segment: an &lt;code&gt;app/[locale]/&lt;/code&gt; dynamic segment, so every page exists at &lt;code&gt;/en/...&lt;/code&gt; and &lt;code&gt;/ar/...&lt;/code&gt;. The locale layout reads the param and sets both &lt;code&gt;lang&lt;/code&gt; and &lt;code&gt;dir&lt;/code&gt; on the &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; element, and &lt;code&gt;generateStaticParams&lt;/code&gt; prerenders both language trees.&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/[locale]/layout.tsx&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;locales&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&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;ar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="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;generateStaticParams&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;locales&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;locale&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;locale&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;LocaleLayout&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="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;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="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="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;locale&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;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="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ar&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;rtl&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;ltr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;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="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;p&gt;Middleware handles exactly one job: a request to a bare path such as &lt;code&gt;/&lt;/code&gt; or &lt;code&gt;/services&lt;/code&gt; gets redirected to its localized twin, negotiated from a saved preference cookie first and the &lt;code&gt;Accept-Language&lt;/code&gt; header second. After that redirect, the URL — not the cookie — is the single source of truth for language. Translations themselves are plain JSON dictionaries I &lt;code&gt;await&lt;/code&gt; inside server components, so no translation strings ship in the client bundle; &lt;code&gt;next-intl&lt;/code&gt; earns its place when you need ICU plurals, rich formatting, and typed message keys, but a two-locale marketing site does not need it on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does cookie-based language switching hurt SEO?
&lt;/h2&gt;

&lt;p&gt;Because a crawler is always a first-time visitor with an empty cookie jar: it requests the URL, receives the default language, and indexes that. Your second language never enters the index. Googlebot does not persist cookies between requests, crawls predominantly from US IP addresses, and does not systematically vary its &lt;code&gt;Accept-Language&lt;/code&gt; header — Google's own documentation recommends separate URLs per language rather than dynamic content on one URL. Cookie switching also breaks &lt;code&gt;hreflang&lt;/code&gt; outright, since &lt;code&gt;hreflang&lt;/code&gt; maps languages to URLs and both languages share one. And it breaks humans too: a link copied by an Arabic reader opens in whatever language the recipient's cookie says, not what the sender was looking at.&lt;/p&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;Cookie-based switching&lt;/th&gt;
&lt;th&gt;URL-prefix routing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;URL shape&lt;/td&gt;
&lt;td&gt;One URL, content varies&lt;/td&gt;
&lt;td&gt;One URL per language&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What crawlers index&lt;/td&gt;
&lt;td&gt;Default language only&lt;/td&gt;
&lt;td&gt;Both languages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hreflang possible&lt;/td&gt;
&lt;td&gt;No — nothing to point at&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared links keep language&lt;/td&gt;
&lt;td&gt;No — recipient's cookie wins&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDN caching&lt;/td&gt;
&lt;td&gt;Needs &lt;code&gt;Vary: Cookie&lt;/code&gt;, poor hit rate&lt;/td&gt;
&lt;td&gt;Plain per-URL caching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Switcher implementation&lt;/td&gt;
&lt;td&gt;Set cookie + reload&lt;/td&gt;
&lt;td&gt;Link to the same path in the other locale&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The migration cost me less than I feared: the switcher became a link to the same pathname under the other prefix, and the cookie survives only to make the bare-URL redirect remember your choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I make a layout flip for RTL without rewriting the CSS?
&lt;/h2&gt;

&lt;p&gt;Set &lt;code&gt;dir="rtl"&lt;/code&gt; on &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; and write every style in CSS logical properties; the browser does the flipping. A physical property such as &lt;code&gt;margin-left&lt;/code&gt; stays on the left in both directions, but the logical &lt;code&gt;margin-inline-start&lt;/code&gt; means "the side where text starts" — left in English, right in Arabic. In Tailwind that is a mechanical substitution: &lt;code&gt;ml-4&lt;/code&gt; becomes &lt;code&gt;ms-4&lt;/code&gt;, &lt;code&gt;pr-6&lt;/code&gt; becomes &lt;code&gt;pe-6&lt;/code&gt;, &lt;code&gt;text-left&lt;/code&gt; becomes &lt;code&gt;text-start&lt;/code&gt;, &lt;code&gt;border-l-2&lt;/code&gt; becomes &lt;code&gt;border-s-2&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Physical utilities — the sidebar sticks to the wrong side in RTL&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;aside&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;"ml-4 pr-6 text-left border-l-2"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

// Logical utilities — flip automatically when dir="rtl"
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;aside&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;"ms-4 pe-6 text-start border-s-2"&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;Flexbox and grid already follow the document direction, so most layout flips for free. What needs manual attention is a short list. Directional icons — back arrows, chevrons, "next" indicators — must mirror, which &lt;code&gt;rtl:-scale-x-100&lt;/code&gt; handles; logos, checkmarks, and media-playback icons must not. Mixed-direction text is the subtle one: an English product name, an email address, or a code identifier inside an Arabic sentence can drag punctuation to the wrong side, and wrapping the embedded token in &lt;code&gt;&amp;lt;bdi&amp;gt;&lt;/code&gt; (or using &lt;code&gt;dir="auto"&lt;/code&gt; on user-generated content) isolates it. For numerals, &lt;code&gt;Intl.NumberFormat&lt;/code&gt; decides between Western digits and Eastern Arabic digits per locale — pick one convention and apply it everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I load Arabic fonts without shipping them to everyone?
&lt;/h2&gt;

&lt;p&gt;Declare one font per script with &lt;code&gt;next/font&lt;/code&gt; and attach only the active locale's font variable in the layout. &lt;code&gt;next/font&lt;/code&gt; self-hosts and subsets at build time, so there is no runtime request to Google Fonts and no layout shift from late-loading glyphs.&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;Inter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;IBM_Plex_Sans_Arabic&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/font/google&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;inter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Inter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;subsets&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;latin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;variable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--font-sans&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;arabic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;IBM_Plex_Sans_Arabic&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;subsets&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;arabic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;weight&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;400&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;500&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;700&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;variable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;--font-sans&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;// in app/[locale]/layout.tsx&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="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;arabic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;inter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variable&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two script-specific notes from shipping this. Arabic has no italic tradition — browsers synthesize a slant that reads as broken, so my Arabic styles never use &lt;code&gt;font-style: italic&lt;/code&gt; and lean on weight for emphasis instead. And Arabic glyphs sit taller than Latin ones, so a line-height tuned for English headlines usually needs loosening for the Arabic rendering of the same component.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I wire hreflang so both languages get indexed?
&lt;/h2&gt;

&lt;p&gt;Return &lt;code&gt;alternates.languages&lt;/code&gt; from &lt;code&gt;generateMetadata&lt;/code&gt; on every page, listing the absolute URL of each translation plus an &lt;code&gt;x-default&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;// app/[locale]/services/page.tsx&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;generateMetadata&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&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;locale&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.devya.dev&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;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/services`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;languages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/en/services`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;ar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/ar/services`&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-default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/en/services`&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;Three rules keep it valid. The annotations must be reciprocal — the English page lists the Arabic URL and the Arabic page lists the English one, and each lists itself. The URLs must be absolute and canonical. And &lt;code&gt;x-default&lt;/code&gt; names the page for visitors matching neither language, normally the English URL. Localize the rest of the metadata while you are there: an Arabic page with an English &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; and meta description looks broken in Arabic search results, and answer engines quoting the page inherit the mismatch.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does Googlebot use cookies or Accept-Language to find my Arabic content?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. Googlebot does not persist cookies and generally crawls without varying &lt;code&gt;Accept-Language&lt;/code&gt;, mostly from US IPs. Content only reachable through a cookie or header switch stays unindexed — give each language its own URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Should Arabic live on a path prefix, a subdomain, or a ccTLD?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; A path prefix (&lt;code&gt;/ar/&lt;/code&gt;) is the cheapest to operate: one deployment, one domain accumulating authority, and hreflang ties the variants together. Subdomains and country TLDs make sense for separate regional businesses, not for a language variant of one site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does dir="rtl" break flexbox or grid?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. Flex rows and grid columns follow the document direction automatically. Breakage comes from physical utilities (&lt;code&gt;ml-*&lt;/code&gt;, &lt;code&gt;text-left&lt;/code&gt;) and absolutely positioned elements pinned with &lt;code&gt;left:&lt;/code&gt;/&lt;code&gt;right:&lt;/code&gt; — replace them with logical equivalents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I need next-intl for a bilingual site?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Not necessarily. An &lt;code&gt;app/[locale]&lt;/code&gt; segment plus JSON dictionaries awaited in server components covers a static bilingual site. &lt;code&gt;next-intl&lt;/code&gt; pays off when you need ICU plurals, date and number formatting, and typed message keys across a large app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Which icons should flip in RTL?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Only directional ones — back and forward arrows, chevrons, progress indicators. Logos, checkmarks, and media-playback controls keep their orientation.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/bilingual-nextjs-arabic-rtl-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/bilingual-nextjs-arabic-rtl-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>i18n</category>
      <category>webdev</category>
      <category>seo</category>
    </item>
    <item>
      <title>Streaming AI Responses in Next.js: SSE, Fetch Streams, and What Breaks in Production</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Thu, 23 Jul 2026 06:07:41 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/streaming-ai-responses-in-nextjs-sse-fetch-streams-and-what-breaks-in-production-4f76</link>
      <guid>https://dev.to/ahmed_mahmoud360/streaming-ai-responses-in-nextjs-sse-fetch-streams-and-what-breaks-in-production-4f76</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Streaming an AI response in the Next.js App Router takes one Route Handler that returns a streaming &lt;code&gt;Response&lt;/code&gt; and one client hook — the AI SDK's &lt;code&gt;streamText&lt;/code&gt; and &lt;code&gt;useChat&lt;/code&gt; handle the wire protocol. The hard parts live around that code: proxies that buffer the stream into a single blob, compression that swallows token chunks, refreshes that kill in-flight generations, and abort wiring that decides whether you keep paying for tokens nobody is reading.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Streaming in the Next.js App Router is a Route Handler returning a streamed Response.&lt;/strong&gt; The AI SDK's &lt;code&gt;streamText&lt;/code&gt; plus &lt;code&gt;toUIMessageStreamResponse()&lt;/code&gt; emit an SSE-based message stream, and the &lt;code&gt;useChat&lt;/code&gt; hook from &lt;code&gt;@ai-sdk/react&lt;/code&gt; consumes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chat UIs do not use the browser &lt;code&gt;EventSource&lt;/code&gt; API.&lt;/strong&gt; &lt;code&gt;EventSource&lt;/code&gt; only supports GET requests with no body, so AI chat clients POST with &lt;code&gt;fetch&lt;/code&gt; and read the SSE-formatted response through a &lt;code&gt;ReadableStream&lt;/code&gt; reader.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A stream that works locally but arrives all at once in production is almost always intermediary buffering&lt;/strong&gt; — compression middleware, nginx &lt;code&gt;proxy_buffering&lt;/code&gt;, or a corporate proxy. &lt;code&gt;Cache-Control: no-transform&lt;/code&gt; and &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; fix most cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passing the request's &lt;code&gt;AbortSignal&lt;/code&gt; into &lt;code&gt;streamText&lt;/code&gt;&lt;/strong&gt; means a closed tab cancels the upstream model call and stops token spend immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A page refresh kills the default fetch stream.&lt;/strong&gt; Persist finished messages in &lt;code&gt;onFinish&lt;/code&gt; on the server; reach for resumable streams only when mid-generation continuity is a real product requirement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every AI fuature I have shipped in the past year — chat assistants, summarizers, report generators — streams its output token by token. Users will sit through a long generation when text appears immediately; they abandon a silent spinner much sooner. The streaming code itself has become short. The failure modes around it are what fill my notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does it take to stream an AI response in Next.js?
&lt;/h2&gt;

&lt;p&gt;One Route Handler and one hook. On the server, &lt;code&gt;streamText&lt;/code&gt; from the AI SDK (Vercel's TypeScript toolkit for calling language models) starts the model call and returns immediately; &lt;code&gt;toUIMessageStreamResponse()&lt;/code&gt; converts the result into a streaming &lt;code&gt;Response&lt;/code&gt; that speaks the SDK's SSE-based UI message stream protocol. On the client, &lt;code&gt;useChat&lt;/code&gt; from &lt;code&gt;@ai-sdk/react&lt;/code&gt; POSTs the conversation, parses the stream, and re-renders as parts arrive.&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/chat/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;streamText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;convertToModelMessages&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;UIMessage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ai&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;maxDuration&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// seconds — cap for long generations&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;messages&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UIMessage&lt;/span&gt;&lt;span class="p"&gt;[]&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;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;streamText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;anthropic/claude-sonnet-4-6&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// AI Gateway provider/model string&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;convertToModelMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;abortSignal&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;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// closed tab → cancel the model call&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUIMessageStreamResponse&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useChat&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@ai-sdk/react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Chat&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;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useChat&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="c1"&gt;// render messages; sendMessage({ text }) on submit; stop() aborts the stream&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 matter on serverless. The function has to live as long as the generation, so I export a &lt;code&gt;maxDuration&lt;/code&gt; from the route — on Vercel, Fluid Compute streams responses and the default execution cap is 300 seconds. And I pass the model as an AI Gateway &lt;code&gt;provider/model&lt;/code&gt; string, which keeps switching providers a one-line change instead of a dependency swap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I use SSE, WebSockets, or plain fetch streaming?
&lt;/h2&gt;

&lt;p&gt;For streaming tokens from server to client, SSE format read over &lt;code&gt;fetch&lt;/code&gt; is the right default, and it is what the AI SDK implements. Server-Sent Events (SSE) is a plain-HTTP wire format where the server writes &lt;code&gt;data:&lt;/code&gt; lines down one long-lived response. WebSockets earn their place only when traffic is genuinely bidirectional — voice conversations, multiplayer cursors, collaborative editing. For a chat box they add connection state and infrastructure without buying anything.&lt;/p&gt;

&lt;p&gt;The subtle point that took me a while to internalize: the browser's built-in &lt;code&gt;EventSource&lt;/code&gt; API is not what chat apps use. &lt;code&gt;EventSource&lt;/code&gt; can only issue GET requests and cannot attach a request body or custom headers, while a chat request needs to POST the conversation history. So the AI SDK sends a normal &lt;code&gt;fetch&lt;/code&gt; POST and parses the SSE format off the response body with a &lt;code&gt;ReadableStream&lt;/code&gt; reader. You get the SSE wire format without the &lt;code&gt;EventSource&lt;/code&gt; limitations — at the cost of its automatic reconnection, which is exactly the gap resumable streams fill later.&lt;/p&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;fetch + SSE format&lt;/th&gt;
&lt;th&gt;EventSource&lt;/th&gt;
&lt;th&gt;WebSockets&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direction&lt;/td&gt;
&lt;td&gt;Server → client&lt;/td&gt;
&lt;td&gt;Server → client&lt;/td&gt;
&lt;td&gt;Bidirectional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request&lt;/td&gt;
&lt;td&gt;Any method, body, headers&lt;/td&gt;
&lt;td&gt;GET only, no body&lt;/td&gt;
&lt;td&gt;Full duplex after upgrade&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reconnection&lt;/td&gt;
&lt;td&gt;Manual (or resumable streams)&lt;/td&gt;
&lt;td&gt;Automatic with Last-Event-ID&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serverless fit&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Poor — needs a persistent connection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;AI chat, token streams&lt;/td&gt;
&lt;td&gt;Tickers, notifications&lt;/td&gt;
&lt;td&gt;Voice, multiplayer, collab editing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why does my stream work locally but arrive all at once in production?
&lt;/h2&gt;

&lt;p&gt;Because something between your function and the browser is buffering. Compression middleware is the most common culprit: gzip and brotli want complete chunks to compress, so small token deltas sit in the compressor's buffer until the response ends and the stream arrives as one block. Reverse proxies are second: nginx's &lt;code&gt;proxy_buffering&lt;/code&gt; collects the entire upstream response by default. Corporate proxies and some antivirus products do the same, and those are entirely outside your control.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A raw streaming Response with buffering-hostile headers&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/event-stream&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-cache, no-transform&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Accel-Buffering&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// tells nginx: do not buffer this 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;On Vercel, streaming responses pass through unbuffered out of the box, so I have only ever debugged this on self-hosted deployments — the classic broken case is Next.js behind an nginx config nobody has touched in a year. If you proxy through nginx, either send &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; or disable &lt;code&gt;proxy_buffering&lt;/code&gt; for the route, and exclude &lt;code&gt;text/event-stream&lt;/code&gt; responses from any compression layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I stop paying for tokens nobody is reading?
&lt;/h2&gt;

&lt;p&gt;Wire aborts end to end. The incoming &lt;code&gt;Request&lt;/code&gt; carries an &lt;code&gt;AbortSignal&lt;/code&gt; that fires when the user closes the tab, navigates away, or clicks stop (the &lt;code&gt;useChat&lt;/code&gt; hook exposes a &lt;code&gt;stop()&lt;/code&gt; that aborts the fetch). Passing it as &lt;code&gt;streamText&lt;/code&gt;'s &lt;code&gt;abortSignal&lt;/code&gt; option propagates the cancellation to the model provider, which stops generating — and stops billing.&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="nf"&gt;streamText&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;anthropic/claude-sonnet-4-6&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;convertToModelMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;abortSignal&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;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// abort upstream on disconnect — saves tokens&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// The opposite policy: finish the generation even if the client leaves,&lt;/span&gt;
&lt;span class="c1"&gt;// so onFinish can persist the complete message.&lt;/span&gt;
&lt;span class="c1"&gt;// result.consumeStream();&lt;/span&gt;

&lt;span class="k"&gt;return&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;toUIMessageStreamResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;onFinish&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;messages&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveChat&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// completed messages survive refresh&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a genuine trade-off in that snippet. &lt;code&gt;consumeStream()&lt;/code&gt; does the opposite of the abort signal: it detaches the generation from the client connection, so the model runs to completion and &lt;code&gt;onFinish&lt;/code&gt; can persist the full message even after a disconnect. Abort-on-disconnect saves tokens but loses the partial response; consume-to-completion keeps the response but pays for every token. I wire the abort signal for cheap conversational chat and consume-plus-persist for expensive long-form generations. The mistake is choosing one policy globally instead of per feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when the user refreshes mid-stream?
&lt;/h2&gt;

&lt;p&gt;With the default setup, the fetch stream dies with the page, and if the abort signal is wired, the server-side generation dies with it. After the reload, the chat shows whatever &lt;code&gt;onFinish&lt;/code&gt; had persisted: completed messages survive, the in-flight one vanishes. For most chat products I consider that acceptable — and more honest than pretending the message still exists somewhere.&lt;/p&gt;

&lt;p&gt;When it is not acceptable — long report generations a user kicks off and checks back on — resumable streams close the gap. Chunks are published to a store such as Redis as they arrive, and a client that reconnects re-subscribes from where it left off, so the generation survives refreshes and network drops. The AI SDK supports this pattern, but it adds real infrastructure and operational state. My rule: persist finished messages always; add resumability only when a product requirement names it explicitly.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Do I need WebSockets to stream ChatGPT-style responses?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. Token streaming is one-way, server to client, and SSE format over a fetch POST handles it on plain HTTP. WebSockets are for bidirectional traffic such as voice or collaborative editing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Why does my AI stream not work behind nginx?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; nginx buffers upstream responses by default. Send &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; on the response or disable &lt;code&gt;proxy_buffering&lt;/code&gt; for the route, and make sure no compression layer re-buffers the stream.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Does streaming work on serverless platforms like Vercel?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Yes. Vercel Functions stream responses, and Fluid Compute keeps the function alive for the whole generation — the default execution cap is 300 seconds, configurable per route with &lt;code&gt;maxDuration&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; How do I save the AI message if the user closes the tab mid-stream?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; Call &lt;code&gt;consumeStream()&lt;/code&gt; on the server so the generation runs to completion and &lt;code&gt;onFinish&lt;/code&gt; persists the full message. This conflicts with aborting to save tokens — each route picks one behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q:&lt;/strong&gt; Can EventSource send a POST body?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A:&lt;/strong&gt; No. &lt;code&gt;EventSource&lt;/code&gt; issues GET requests only, with no body or custom headers, which is why AI chat clients read the SSE format from a fetch POST via a &lt;code&gt;ReadableStream&lt;/code&gt; reader instead.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/streaming-ai-responses-nextjs-sse-field-notes" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/streaming-ai-responses-nextjs-sse-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>ai</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>What Changed in Zod 4, and How I Migrated Production Schemas</title>
      <dc:creator>Ahmed Mahmoud</dc:creator>
      <pubDate>Wed, 22 Jul 2026 06:01:49 +0000</pubDate>
      <link>https://dev.to/ahmed_mahmoud360/what-changed-in-zod-4-and-how-i-migrated-production-schemas-di0</link>
      <guid>https://dev.to/ahmed_mahmoud360/what-changed-in-zod-4-and-how-i-migrated-production-schemas-di0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Headline:&lt;/strong&gt; Zod 4 is a rewrite of the TypeScript-first schema validation library, released as the stable major in 2025. Four changes hit my code directly: string formats moved to top-level functions (&lt;code&gt;z.email()&lt;/code&gt; instead of &lt;code&gt;z.string().email()&lt;/code&gt;), the four error options collapsed into one &lt;code&gt;error&lt;/code&gt; parameter, error formatting moved to standalone helpers (&lt;code&gt;z.flattenError&lt;/code&gt;, &lt;code&gt;z.treeifyError&lt;/code&gt;, &lt;code&gt;z.prettifyError&lt;/code&gt;), and &lt;code&gt;.strict()&lt;/code&gt;/&lt;code&gt;.passthrough()&lt;/code&gt; became &lt;code&gt;z.strictObject()&lt;/code&gt;/&lt;code&gt;z.looseObject()&lt;/code&gt;. The deprecated Zod 3 APIs still work with warnings, so I migrated incrementally.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;Zod 4 is the stable major of the TypeScript-first schema validator, released in 2025; it requires TypeScript 5.5 or newer.&lt;/li&gt;
&lt;li&gt;String formats are now top-level tree-shakeable functions — &lt;code&gt;z.email()&lt;/code&gt;, &lt;code&gt;z.uuid()&lt;/code&gt;, &lt;code&gt;z.url()&lt;/code&gt; — and &lt;code&gt;z.string().email()&lt;/code&gt; is deprecated but still works.&lt;/li&gt;
&lt;li&gt;A single &lt;code&gt;error&lt;/code&gt; parameter replaces Zod 3's &lt;code&gt;message&lt;/code&gt;, &lt;code&gt;invalid_type_error&lt;/code&gt;, &lt;code&gt;required_error&lt;/code&gt;, and &lt;code&gt;errorMap&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Error formatting moved to &lt;code&gt;z.flattenError&lt;/code&gt; (form fields), &lt;code&gt;z.treeifyError&lt;/code&gt; (nested), and &lt;code&gt;z.prettifyError&lt;/code&gt; (human-readable string).&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;zod/mini&lt;/code&gt; build exposes the same validators through a functional, tree-shakeable API; &lt;code&gt;z.infer&lt;/code&gt;, &lt;code&gt;.parse()&lt;/code&gt;, and &lt;code&gt;.safeParse()&lt;/code&gt; did not change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I reach for Zod on almost every project to validate untrusted input at the boundary — request bodies, form data, environment variables, API responses. Zod 4 changed enough of the surface that a mechanical upgrade tripped a handful of files, so I mapped exactly what moved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed in Zod 4?
&lt;/h2&gt;

&lt;p&gt;Zod 4 is a ground-up rewrite of the TypeScript-first schema validation library, released as the stable major in 2025. The headline is performance: the Zod team's release notes report large reductions in TypeScript compiler instantiations and faster runtime parsing, which matters most in large codebases where schema types dominate type-check time. Four API changes touched my code directly — string formats moved to top-level functions, the four error options collapsed into one, error formatting moved to standalone helpers, and &lt;code&gt;.strict()&lt;/code&gt;/&lt;code&gt;.passthrough()&lt;/code&gt; became &lt;code&gt;z.strictObject()&lt;/code&gt;/&lt;code&gt;z.looseObject()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did z.string().email() become z.email()?
&lt;/h2&gt;

&lt;p&gt;Zod 4 promotes string formats to standalone top-level functions — &lt;code&gt;z.email()&lt;/code&gt;, &lt;code&gt;z.uuid()&lt;/code&gt;, &lt;code&gt;z.url()&lt;/code&gt;, and the ISO helpers under &lt;code&gt;z.iso&lt;/code&gt; — instead of methods chained onto &lt;code&gt;z.string()&lt;/code&gt;. The chained form still works but is deprecated and warns. The reason is tree-shaking: each format is its own function, so a bundle that only validates emails no longer ships the logic for every other format.&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;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;User&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;id&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;uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;website&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;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;age&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;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&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;18&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;User&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="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;User&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I set custom error messages in Zod 4?
&lt;/h2&gt;

&lt;p&gt;Zod 4 replaces four separate error options with a single &lt;code&gt;error&lt;/code&gt; parameter. In Zod 3 you passed &lt;code&gt;message&lt;/code&gt;, &lt;code&gt;invalid_type_error&lt;/code&gt;, &lt;code&gt;required_error&lt;/code&gt;, or a full &lt;code&gt;errorMap&lt;/code&gt;. In Zod 4 you pass one &lt;code&gt;error&lt;/code&gt;: a string for a fixed message, or a function that receives the issue and returns a message, letting you distinguish a missing value from a wrong type in one place.&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;// Zod 4: one error param — string or function&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Name is required&lt;/span&gt;&lt;span class="dl"&gt;"&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="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;issue&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;issue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;input&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Name is required&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Name must be text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I turn a ZodError into form errors?
&lt;/h2&gt;

&lt;p&gt;Zod 4 moves error formatting into three top-level helpers. &lt;code&gt;z.flattenError(error)&lt;/code&gt; returns &lt;code&gt;{ formErrors, fieldErrors }&lt;/code&gt;, which maps onto a form's field-level messages. &lt;code&gt;z.treeifyError(error)&lt;/code&gt; returns a nested object mirroring the schema shape. &lt;code&gt;z.prettifyError(error)&lt;/code&gt; returns a human-readable multiline string for logs. The raw &lt;code&gt;error.issues&lt;/code&gt; array is unchanged; &lt;code&gt;error.format()&lt;/code&gt; and &lt;code&gt;error.flatten()&lt;/code&gt; are deprecated in favor of these helpers.&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="nx"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fieldErrors&lt;/span&gt; &lt;span class="p"&gt;}&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;flattenError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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;errors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fieldErrors&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="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="nx"&gt;result&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="c1"&gt;// fully typed as User&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When should I use zod/mini instead of the full zod package?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;zod/mini&lt;/code&gt; when bundle size matters — client code, edge functions, a shipped widget — and the full &lt;code&gt;zod&lt;/code&gt; package everywhere else. &lt;code&gt;zod/mini&lt;/code&gt; exposes the same validators through a functional API: instead of chaining &lt;code&gt;.optional()&lt;/code&gt; you wrap with &lt;code&gt;z.optional()&lt;/code&gt;, and refinements use &lt;code&gt;.check()&lt;/code&gt; rather than &lt;code&gt;.refine()&lt;/code&gt;. It is more verbose, but with no method chain, unused code tree-shakes away. Both builds share the same core, so runtime behavior is identical.&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod/mini&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;User&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;email&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;email&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/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;zod (full)&lt;/th&gt;
&lt;th&gt;zod/mini&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API style&lt;/td&gt;
&lt;td&gt;Chained methods (&lt;code&gt;.optional()&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Functional wrappers (&lt;code&gt;z.optional()&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundle size&lt;/td&gt;
&lt;td&gt;Larger&lt;/td&gt;
&lt;td&gt;Smaller, tree-shakeable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ergonomics&lt;/td&gt;
&lt;td&gt;Fluent, readable&lt;/td&gt;
&lt;td&gt;More verbose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same validators&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes (same core)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Servers, general code&lt;/td&gt;
&lt;td&gt;Edge/client, size-critical bundles&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How do I migrate from Zod 3 without breaking everything?
&lt;/h2&gt;

&lt;p&gt;Migrate incrementally, because Zod 4 keeps the deprecated Zod 3 APIs working with warnings rather than removing them. During the transition Zod published the new version under the &lt;code&gt;zod/v4&lt;/code&gt; import path (in &lt;code&gt;zod@3.25&lt;/code&gt;) so you could adopt it file by file before &lt;code&gt;zod@4.0&lt;/code&gt;; the legacy API stays reachable at &lt;code&gt;zod/v3&lt;/code&gt;. My process: upgrade the package, run the type-checker and tests, then clear deprecation warnings in waves — rename string-format calls, collapse error options into &lt;code&gt;error&lt;/code&gt;, and switch &lt;code&gt;.strict()&lt;/code&gt;/&lt;code&gt;.passthrough()&lt;/code&gt; to the new object functions. &lt;code&gt;z.infer&lt;/code&gt;, &lt;code&gt;.parse()&lt;/code&gt;, and &lt;code&gt;.safeParse()&lt;/code&gt; did not change, so most schemas kept working untouched.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Q: Is z.string().email() removed in Zod 4?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. It still works but is deprecated and warns. The recommended form is the top-level &lt;code&gt;z.email()&lt;/code&gt;, and both validate identically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: What replaced errorMap and invalid_type_error in Zod 4?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; A single &lt;code&gt;error&lt;/code&gt; parameter — a string for a fixed message or a function that receives the issue for conditional messages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: How do I get field-level errors for a form in Zod 4?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Call &lt;code&gt;z.flattenError(error)&lt;/code&gt;; its &lt;code&gt;fieldErrors&lt;/code&gt; maps each schema key to its messages. Use &lt;code&gt;z.treeifyError&lt;/code&gt; for nested shapes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Is zod/mini a separate library?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; No. It is a build of Zod 4 with the same validators exposed through a functional, tree-shakeable API for smaller bundles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Does Zod 4 require a specific TypeScript version?&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;A:&lt;/strong&gt; Yes. Zod 4 requires TypeScript 5.5 or newer.&lt;/p&gt;




&lt;p&gt;Originally published on &lt;a href="https://www.devya.dev/blogs/zod-4-field-notes-2026" rel="noopener noreferrer"&gt;devya.dev&lt;/a&gt;. Also on &lt;a href="https://www.eng-ahmed.com/blog/zod-4-field-notes-2026" 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>typescript</category>
      <category>zod</category>
      <category>webdev</category>
      <category>node</category>
    </item>
  </channel>
</rss>
